agents-city 0.4.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +240 -11
- package/README.md +238 -11
- package/benchmarks/latency/fake-claude-cli.mjs +2 -1
- package/benchmarks/reception/README.md +16 -0
- package/benchmarks/reception/run.mjs +84 -0
- package/bin/agents-city.js +1 -0
- package/bin/connect +5 -0
- package/bin/hall.html +117 -18
- package/bin/navegador.mjs +170 -1
- package/bin/serve.py +201 -101
- package/bin/test +15 -3
- package/bin/test-arnes.py +192 -0
- package/bin/test-cage.py +9 -0
- package/bin/test-channel.py +178 -12
- package/bin/test-claude-runtime.py +132 -4
- package/bin/test-connect-client.mjs +609 -0
- package/bin/test-connect.py +52 -0
- package/bin/test-contracts.py +214 -0
- package/bin/test-diario.py +185 -0
- package/bin/test-doctor.py +31 -0
- package/bin/test-i18n.py +189 -0
- package/bin/test-navegador.py +61 -0
- package/bin/test-seat.py +78 -20
- package/bin/test-security.py +2 -0
- package/bin/test-serve.py +366 -0
- package/city/web/dist-hall/hall.js +789 -214
- package/city/web/src/bienvenida.ts +14 -13
- package/city/web/src/casa.ts +143 -56
- package/city/web/src/demo.ts +61 -31
- package/city/web/src/es.ts +187 -27
- package/city/web/src/explorador.ts +70 -43
- package/city/web/src/hall.ts +520 -103
- package/city/web/src/idioma.ts +21 -1
- package/city/web/src/motores.ts +48 -1
- package/city/web/src/vista.ts +58 -0
- package/demo/graba.py +5 -10
- package/docs/managed-connect.md +237 -0
- package/docs/security.md +41 -2
- package/package.json +8 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/adapter-prompts.ts +10 -0
- package/plugin/channel/adapter.js +78 -31
- package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
- package/plugin/channel/bus.js +109 -65
- package/plugin/channel/bus.ts +11 -1
- package/plugin/channel/client.js +67 -30
- package/plugin/channel/delivery-queue.ts +160 -21
- package/plugin/channel/hub/remote-roads.ts +32 -1
- package/plugin/channel/hub/road-controller.ts +116 -16
- package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
- package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
- package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
- package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
- package/plugin/channel/licenses/keyring-MIT.txt +21 -0
- package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
- package/plugin/channel/local-hub.js +2080 -97
- package/plugin/channel/local-hub.ts +5 -4
- package/plugin/channel/managed-connect/bridge.ts +208 -0
- package/plugin/channel/managed-connect/cli.ts +416 -0
- package/plugin/channel/managed-connect/device.ts +15 -0
- package/plugin/channel/managed-connect/local-cities.ts +94 -0
- package/plugin/channel/managed-connect/person-message.ts +74 -0
- package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
- package/plugin/channel/managed-connect/relay-session.ts +7 -0
- package/plugin/channel/managed-connect/storage.ts +653 -0
- package/plugin/channel/managed-connect/transport.ts +87 -0
- package/plugin/channel/managed-connect-cli.js +4777 -0
- package/plugin/channel/managed-connect-cli.ts +7 -0
- package/plugin/channel/managed-connect-client.d.ts +261 -0
- package/plugin/channel/managed-connect-client.js +6572 -0
- package/plugin/channel/managed-connect-client.manifest.json +38 -0
- package/plugin/channel/package-lock.json +123 -105
- package/plugin/channel/package.json +4 -4
- package/plugin/channel/protocol.ts +4 -0
- package/plugin/channel/reception.ts +896 -0
- package/plugin/channel/road-cli.ts +1 -1
- package/plugin/channel/runtime/arnes.json +189 -0
- package/plugin/channel/runtime/arnes.ts +47 -0
- package/plugin/channel/runtime/claude.ts +18 -0
- package/plugin/channel/runtime/codex-config.ts +51 -1
- package/plugin/channel/runtime/codex.ts +31 -11
- package/plugin/channel/runtime/kimi.ts +6 -4
- package/plugin/channel/runtime-files.ts +39 -5
- package/plugin/channel/runtime-gateway.js +426 -100
- package/plugin/channel/runtime-gateway.ts +12 -0
- package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
- package/plugin/scripts/arnes.py +271 -0
- package/plugin/scripts/busca.py +46 -10
- package/plugin/scripts/cage.py +5 -0
- package/plugin/scripts/city-session.sh +144 -28
- package/plugin/scripts/crecimiento.py +4 -1
- package/plugin/scripts/demos.py +58 -43
- package/plugin/scripts/desinstala.py +36 -21
- package/plugin/scripts/diario.py +119 -0
- package/plugin/scripts/doctor.py +90 -15
- package/plugin/scripts/read-card.py +48 -8
- package/plugin/scripts/reception.py +665 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The local, owner-level reception for remote messages.
|
|
3
|
+
|
|
4
|
+
The managed client decrypts into a private SQLite quarantine shared by every
|
|
5
|
+
city on this computer. The Hall may display that text as inert HTML-escaped
|
|
6
|
+
content and make one atomic decision: reject it with a reason, or route it to
|
|
7
|
+
one or more local cities. City buses consume only the resulting route rows;
|
|
8
|
+
they never query pending messages.
|
|
9
|
+
|
|
10
|
+
SQLite is intentional here: several city processes and the threaded Hall can
|
|
11
|
+
touch the same reception. WAL plus ``BEGIN IMMEDIATE`` gives a serial decision
|
|
12
|
+
boundary without a server, account database, or public port on this computer.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
import unicodedata
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
import cities
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
PROTOCOL = "agents-city-reception/1"
|
|
27
|
+
SCHEMA_VERSION = 3
|
|
28
|
+
AUTO_ROUTER_PROFILE = "deterministic-rules/1"
|
|
29
|
+
MESSAGE_ID = re.compile(r"^[A-Za-z0-9_.-]{1,180}$")
|
|
30
|
+
CONNECTION_ID = re.compile(
|
|
31
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
MAX_DESTINATIONS = 32
|
|
35
|
+
MAX_OUTBOX_BYTES = 11_500
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SCHEMA = f"""
|
|
39
|
+
CREATE TABLE IF NOT EXISTS reception_meta (
|
|
40
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
41
|
+
schema_version INTEGER NOT NULL,
|
|
42
|
+
created_at TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS reception_settings (
|
|
45
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
46
|
+
routing_mode TEXT NOT NULL DEFAULT 'manual' CHECK (routing_mode IN ('manual', 'auto')),
|
|
47
|
+
review_policy TEXT NOT NULL DEFAULT 'every_message' CHECK (
|
|
48
|
+
review_policy IN ('every_message', 'new_thread')
|
|
49
|
+
),
|
|
50
|
+
router_profile TEXT CHECK (router_profile IS NULL OR length(router_profile) <= 160),
|
|
51
|
+
updated_at TEXT NOT NULL,
|
|
52
|
+
CHECK (routing_mode = 'manual' OR router_profile IS NOT NULL)
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS reception_messages (
|
|
55
|
+
message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 1 AND 180),
|
|
56
|
+
protocol TEXT NOT NULL CHECK (protocol = '{PROTOCOL}'),
|
|
57
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'routed', 'rejected', 'expired')),
|
|
58
|
+
source_city TEXT NOT NULL CHECK (length(source_city) BETWEEN 3 AND 160),
|
|
59
|
+
source_created_at TEXT NOT NULL,
|
|
60
|
+
source_name TEXT CHECK (source_name IS NULL OR length(source_name) <= 100),
|
|
61
|
+
message_kind TEXT NOT NULL DEFAULT 'message' CHECK (
|
|
62
|
+
message_kind IN ('message', 'rejection')
|
|
63
|
+
),
|
|
64
|
+
in_reply_to TEXT CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180),
|
|
65
|
+
received_city_id TEXT NOT NULL CHECK (length(received_city_id) BETWEEN 1 AND 160),
|
|
66
|
+
received_city_address TEXT NOT NULL CHECK (length(received_city_address) BETWEEN 3 AND 160),
|
|
67
|
+
body TEXT,
|
|
68
|
+
body_sha256 TEXT NOT NULL CHECK (length(body_sha256) = 64),
|
|
69
|
+
connection_id TEXT,
|
|
70
|
+
road_id TEXT,
|
|
71
|
+
remote_message_id TEXT,
|
|
72
|
+
received_at TEXT NOT NULL,
|
|
73
|
+
decided_at TEXT,
|
|
74
|
+
decision_reason TEXT CHECK (decision_reason IS NULL OR length(decision_reason) <= 500),
|
|
75
|
+
CHECK (state = 'pending' OR decided_at IS NOT NULL),
|
|
76
|
+
CHECK (state <> 'pending' OR body IS NOT NULL)
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_reception_messages_state_age
|
|
79
|
+
ON reception_messages (state, received_at, message_id);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS reception_routes (
|
|
81
|
+
message_id TEXT NOT NULL REFERENCES reception_messages(message_id) ON DELETE CASCADE,
|
|
82
|
+
target_city_id TEXT NOT NULL CHECK (length(target_city_id) BETWEEN 1 AND 160),
|
|
83
|
+
target_city_address TEXT NOT NULL CHECK (length(target_city_address) BETWEEN 3 AND 160),
|
|
84
|
+
state TEXT NOT NULL DEFAULT 'queued' CHECK (state IN ('queued', 'delivered', 'failed')),
|
|
85
|
+
approved_by TEXT NOT NULL CHECK (approved_by IN ('human', 'auto')),
|
|
86
|
+
approved_at TEXT NOT NULL,
|
|
87
|
+
delivered_at TEXT,
|
|
88
|
+
error TEXT CHECK (error IS NULL OR length(error) <= 300),
|
|
89
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
90
|
+
last_attempt_at INTEGER,
|
|
91
|
+
next_attempt_at INTEGER,
|
|
92
|
+
PRIMARY KEY (message_id, target_city_id)
|
|
93
|
+
);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_reception_routes_city_state
|
|
95
|
+
ON reception_routes (target_city_id, state, approved_at);
|
|
96
|
+
CREATE TABLE IF NOT EXISTS reception_counters (
|
|
97
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
98
|
+
pending_count INTEGER NOT NULL DEFAULT 0 CHECK (pending_count >= 0),
|
|
99
|
+
pending_bytes INTEGER NOT NULL DEFAULT 0 CHECK (pending_bytes >= 0)
|
|
100
|
+
);
|
|
101
|
+
CREATE TABLE IF NOT EXISTS reception_connections (
|
|
102
|
+
road_id TEXT PRIMARY KEY,
|
|
103
|
+
connection_id TEXT NOT NULL UNIQUE,
|
|
104
|
+
peer_name TEXT NOT NULL CHECK (length(peer_name) BETWEEN 1 AND 100),
|
|
105
|
+
peer_endpoint TEXT NOT NULL CHECK (length(peer_endpoint) BETWEEN 3 AND 160),
|
|
106
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'inactive')),
|
|
107
|
+
updated_at TEXT NOT NULL
|
|
108
|
+
);
|
|
109
|
+
CREATE TABLE IF NOT EXISTS reception_outbox (
|
|
110
|
+
message_id TEXT PRIMARY KEY CHECK (length(message_id) = 36),
|
|
111
|
+
road_id TEXT NOT NULL,
|
|
112
|
+
connection_id TEXT NOT NULL,
|
|
113
|
+
kind TEXT NOT NULL CHECK (kind IN ('message', 'rejection')),
|
|
114
|
+
body TEXT,
|
|
115
|
+
in_reply_to TEXT CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180),
|
|
116
|
+
state TEXT NOT NULL DEFAULT 'queued' CHECK (state IN ('queued', 'sent')),
|
|
117
|
+
created_at TEXT NOT NULL,
|
|
118
|
+
sent_at TEXT,
|
|
119
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
120
|
+
last_attempt_at INTEGER,
|
|
121
|
+
next_attempt_at INTEGER,
|
|
122
|
+
error TEXT CHECK (error IS NULL OR length(error) <= 300)
|
|
123
|
+
);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_reception_outbox_state_age
|
|
125
|
+
ON reception_outbox (state, next_attempt_at, created_at);
|
|
126
|
+
CREATE TABLE IF NOT EXISTS reception_auto_rules (
|
|
127
|
+
rule_id TEXT PRIMARY KEY CHECK (length(rule_id) = 36),
|
|
128
|
+
target_city_id TEXT NOT NULL UNIQUE CHECK (length(target_city_id) BETWEEN 1 AND 160),
|
|
129
|
+
target_city_address TEXT NOT NULL CHECK (length(target_city_address) BETWEEN 3 AND 160),
|
|
130
|
+
keywords_json TEXT NOT NULL CHECK (length(keywords_json) BETWEEN 5 AND 2000),
|
|
131
|
+
priority INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 1000),
|
|
132
|
+
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
133
|
+
updated_at TEXT NOT NULL
|
|
134
|
+
);
|
|
135
|
+
CREATE TRIGGER IF NOT EXISTS reception_message_count_after_insert
|
|
136
|
+
AFTER INSERT ON reception_messages WHEN NEW.state = 'pending'
|
|
137
|
+
BEGIN
|
|
138
|
+
UPDATE reception_counters
|
|
139
|
+
SET pending_count = pending_count + 1,
|
|
140
|
+
pending_bytes = pending_bytes + length(CAST(NEW.body AS BLOB))
|
|
141
|
+
WHERE singleton = 1;
|
|
142
|
+
END;
|
|
143
|
+
CREATE TRIGGER IF NOT EXISTS reception_message_count_after_decision
|
|
144
|
+
AFTER UPDATE OF state ON reception_messages
|
|
145
|
+
WHEN OLD.state = 'pending' AND NEW.state <> 'pending'
|
|
146
|
+
BEGIN
|
|
147
|
+
UPDATE reception_counters
|
|
148
|
+
SET pending_count = MAX(0, pending_count - 1),
|
|
149
|
+
pending_bytes = MAX(0, pending_bytes - length(CAST(OLD.body AS BLOB)))
|
|
150
|
+
WHERE singleton = 1;
|
|
151
|
+
END;
|
|
152
|
+
CREATE TRIGGER IF NOT EXISTS reception_message_count_after_delete
|
|
153
|
+
AFTER DELETE ON reception_messages WHEN OLD.state = 'pending'
|
|
154
|
+
BEGIN
|
|
155
|
+
UPDATE reception_counters
|
|
156
|
+
SET pending_count = MAX(0, pending_count - 1),
|
|
157
|
+
pending_bytes = MAX(0, pending_bytes - length(CAST(OLD.body AS BLOB)))
|
|
158
|
+
WHERE singleton = 1;
|
|
159
|
+
END;
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ReceptionError(ValueError):
|
|
164
|
+
"""A safe, user-facing refusal from the reception boundary."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ahora():
|
|
168
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _directorio():
|
|
172
|
+
base = os.path.realpath(cities.raiz())
|
|
173
|
+
runtime = os.path.join(base, ".runtime")
|
|
174
|
+
recepcion = os.path.join(runtime, "reception")
|
|
175
|
+
for ruta in (runtime, recepcion):
|
|
176
|
+
if not os.path.exists(ruta):
|
|
177
|
+
os.mkdir(ruta, mode=0o700)
|
|
178
|
+
if not os.path.isdir(ruta) or os.path.islink(ruta):
|
|
179
|
+
raise ReceptionError(f"unsafe reception directory: {ruta}")
|
|
180
|
+
os.chmod(ruta, 0o700)
|
|
181
|
+
return recepcion
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def ruta_base():
|
|
185
|
+
return os.path.join(_directorio(), "reception.sqlite3")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _conecta():
|
|
189
|
+
ruta = ruta_base()
|
|
190
|
+
if os.path.lexists(ruta):
|
|
191
|
+
if not os.path.isfile(ruta) or os.path.islink(ruta):
|
|
192
|
+
raise ReceptionError("unsafe reception database")
|
|
193
|
+
db = sqlite3.connect(ruta, timeout=5, isolation_level=None)
|
|
194
|
+
db.row_factory = sqlite3.Row
|
|
195
|
+
try:
|
|
196
|
+
os.chmod(ruta, 0o600)
|
|
197
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
198
|
+
db.execute("PRAGMA synchronous = FULL")
|
|
199
|
+
db.execute("PRAGMA foreign_keys = ON")
|
|
200
|
+
db.execute("PRAGMA busy_timeout = 5000")
|
|
201
|
+
db.executescript(SCHEMA)
|
|
202
|
+
meta = db.execute(
|
|
203
|
+
"SELECT schema_version FROM reception_meta WHERE singleton = 1"
|
|
204
|
+
).fetchone()
|
|
205
|
+
if meta and meta["schema_version"] == 1:
|
|
206
|
+
db.execute("BEGIN IMMEDIATE")
|
|
207
|
+
try:
|
|
208
|
+
actual = db.execute(
|
|
209
|
+
"SELECT schema_version FROM reception_meta WHERE singleton = 1"
|
|
210
|
+
).fetchone()
|
|
211
|
+
if actual and actual["schema_version"] == 1:
|
|
212
|
+
db.execute(
|
|
213
|
+
"""ALTER TABLE reception_messages ADD COLUMN source_name TEXT
|
|
214
|
+
CHECK (source_name IS NULL OR length(source_name) <= 100)"""
|
|
215
|
+
)
|
|
216
|
+
db.execute(
|
|
217
|
+
"""ALTER TABLE reception_messages ADD COLUMN message_kind TEXT
|
|
218
|
+
NOT NULL DEFAULT 'message'
|
|
219
|
+
CHECK (message_kind IN ('message', 'rejection'))"""
|
|
220
|
+
)
|
|
221
|
+
db.execute(
|
|
222
|
+
"""ALTER TABLE reception_messages ADD COLUMN in_reply_to TEXT
|
|
223
|
+
CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180)"""
|
|
224
|
+
)
|
|
225
|
+
db.execute(
|
|
226
|
+
"UPDATE reception_meta SET schema_version = ? WHERE singleton = 1",
|
|
227
|
+
(SCHEMA_VERSION,),
|
|
228
|
+
)
|
|
229
|
+
elif actual and actual["schema_version"] == 2:
|
|
230
|
+
db.execute(
|
|
231
|
+
"UPDATE reception_meta SET schema_version = ? WHERE singleton = 1",
|
|
232
|
+
(SCHEMA_VERSION,),
|
|
233
|
+
)
|
|
234
|
+
elif not actual or actual["schema_version"] != SCHEMA_VERSION:
|
|
235
|
+
raise ReceptionError("unsupported reception schema")
|
|
236
|
+
db.execute("COMMIT")
|
|
237
|
+
except Exception:
|
|
238
|
+
db.execute("ROLLBACK")
|
|
239
|
+
raise
|
|
240
|
+
elif meta and meta["schema_version"] != SCHEMA_VERSION:
|
|
241
|
+
raise ReceptionError(
|
|
242
|
+
f"unsupported reception schema: {meta['schema_version']}"
|
|
243
|
+
)
|
|
244
|
+
ahora = _ahora()
|
|
245
|
+
db.execute(
|
|
246
|
+
"INSERT OR IGNORE INTO reception_meta VALUES (1, ?, ?)",
|
|
247
|
+
(SCHEMA_VERSION, ahora),
|
|
248
|
+
)
|
|
249
|
+
db.execute(
|
|
250
|
+
"""INSERT OR IGNORE INTO reception_settings
|
|
251
|
+
(singleton, routing_mode, review_policy, router_profile, updated_at)
|
|
252
|
+
VALUES (1, 'manual', 'every_message', NULL, ?)""",
|
|
253
|
+
(ahora,),
|
|
254
|
+
)
|
|
255
|
+
db.execute(
|
|
256
|
+
"INSERT OR IGNORE INTO reception_counters VALUES (1, 0, 0)"
|
|
257
|
+
)
|
|
258
|
+
return db
|
|
259
|
+
except Exception:
|
|
260
|
+
db.close()
|
|
261
|
+
raise
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _ciudades(usuario, actual=""):
|
|
265
|
+
candidatas = list(cities.lista(usuario))
|
|
266
|
+
if actual:
|
|
267
|
+
real = os.path.realpath(actual)
|
|
268
|
+
if not any(os.path.realpath(c["ruta"]) == real for c in candidatas):
|
|
269
|
+
owner = cities.lee_clave(real, "owner") or usuario
|
|
270
|
+
if cities.es_ciudad(real) and owner == usuario:
|
|
271
|
+
candidatas.append(
|
|
272
|
+
{
|
|
273
|
+
"ruta": real,
|
|
274
|
+
"nombre": cities.nombre(real),
|
|
275
|
+
"slug": cities.slug_ciudad(real),
|
|
276
|
+
"id": cities.identidad(real),
|
|
277
|
+
"owner": owner,
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
fuera = []
|
|
281
|
+
for ciudad in candidatas:
|
|
282
|
+
ident = str(ciudad.get("id") or "")
|
|
283
|
+
slug = str(ciudad.get("slug") or cities.slug_ciudad(ciudad["ruta"]))
|
|
284
|
+
owner = str(ciudad.get("owner") or usuario)
|
|
285
|
+
if not ident or not slug:
|
|
286
|
+
continue
|
|
287
|
+
fuera.append(
|
|
288
|
+
{
|
|
289
|
+
"id": ident,
|
|
290
|
+
"name": str(ciudad.get("nombre") or slug),
|
|
291
|
+
"address": f"{owner}/{slug}",
|
|
292
|
+
"path": ciudad["ruta"],
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
return fuera
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def estado(usuario, limite=200, actual=""):
|
|
299
|
+
"""Return only pending inert text plus local destinations and counters."""
|
|
300
|
+
limite = max(1, min(int(limite), 200))
|
|
301
|
+
db = _conecta()
|
|
302
|
+
try:
|
|
303
|
+
ajustes = db.execute(
|
|
304
|
+
"""SELECT routing_mode, review_policy, router_profile, updated_at
|
|
305
|
+
FROM reception_settings WHERE singleton = 1"""
|
|
306
|
+
).fetchone()
|
|
307
|
+
contadores = db.execute(
|
|
308
|
+
"SELECT pending_count, pending_bytes FROM reception_counters WHERE singleton = 1"
|
|
309
|
+
).fetchone()
|
|
310
|
+
mensajes = db.execute(
|
|
311
|
+
"""SELECT message_id, source_city, source_name, source_created_at,
|
|
312
|
+
message_kind, in_reply_to,
|
|
313
|
+
received_city_id, received_city_address, body,
|
|
314
|
+
connection_id, road_id, received_at
|
|
315
|
+
FROM reception_messages
|
|
316
|
+
WHERE state = 'pending'
|
|
317
|
+
ORDER BY received_at, message_id LIMIT ?""",
|
|
318
|
+
(limite,),
|
|
319
|
+
).fetchall()
|
|
320
|
+
conexiones = db.execute(
|
|
321
|
+
"""SELECT connection_id, road_id, peer_name, peer_endpoint
|
|
322
|
+
FROM reception_connections WHERE status = 'active'
|
|
323
|
+
ORDER BY peer_name COLLATE NOCASE, connection_id"""
|
|
324
|
+
).fetchall()
|
|
325
|
+
salientes = db.execute(
|
|
326
|
+
"SELECT COUNT(*) AS queued FROM reception_outbox WHERE state = 'queued'"
|
|
327
|
+
).fetchone()
|
|
328
|
+
reglas = db.execute(
|
|
329
|
+
"""SELECT target_city_id, target_city_address, keywords_json, priority
|
|
330
|
+
FROM reception_auto_rules WHERE enabled = 1
|
|
331
|
+
ORDER BY priority DESC, rule_id"""
|
|
332
|
+
).fetchall()
|
|
333
|
+
ciudades = _ciudades(usuario, actual)
|
|
334
|
+
return {
|
|
335
|
+
"protocol": PROTOCOL,
|
|
336
|
+
"settings": {
|
|
337
|
+
"routingMode": ajustes["routing_mode"],
|
|
338
|
+
"reviewPolicy": ajustes["review_policy"],
|
|
339
|
+
"routerProfile": ajustes["router_profile"],
|
|
340
|
+
"autoAvailable": bool(reglas),
|
|
341
|
+
"autoRules": [
|
|
342
|
+
{
|
|
343
|
+
"cityId": fila["target_city_id"],
|
|
344
|
+
"address": fila["target_city_address"],
|
|
345
|
+
"keywords": json.loads(fila["keywords_json"]),
|
|
346
|
+
}
|
|
347
|
+
for fila in reglas
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
"summary": {
|
|
351
|
+
"pending": int(contadores["pending_count"]),
|
|
352
|
+
"pendingBytes": int(contadores["pending_bytes"]),
|
|
353
|
+
"shown": len(mensajes),
|
|
354
|
+
},
|
|
355
|
+
"messages": [
|
|
356
|
+
{
|
|
357
|
+
"id": fila["message_id"],
|
|
358
|
+
"from": fila["source_city"],
|
|
359
|
+
"fromName": fila["source_name"] or fila["source_city"],
|
|
360
|
+
"kind": fila["message_kind"],
|
|
361
|
+
"inReplyTo": fila["in_reply_to"],
|
|
362
|
+
"createdAt": fila["source_created_at"],
|
|
363
|
+
"receivedAt": fila["received_at"],
|
|
364
|
+
"receivedVia": fila["received_city_address"],
|
|
365
|
+
"text": fila["body"],
|
|
366
|
+
"connectionId": fila["connection_id"],
|
|
367
|
+
"roadId": fila["road_id"],
|
|
368
|
+
"agentExposure": False,
|
|
369
|
+
}
|
|
370
|
+
for fila in mensajes
|
|
371
|
+
],
|
|
372
|
+
"cities": [
|
|
373
|
+
{"id": c["id"], "name": c["name"], "address": c["address"]}
|
|
374
|
+
for c in ciudades
|
|
375
|
+
],
|
|
376
|
+
"connections": [
|
|
377
|
+
{
|
|
378
|
+
"id": fila["connection_id"],
|
|
379
|
+
"roadId": fila["road_id"],
|
|
380
|
+
"name": fila["peer_name"],
|
|
381
|
+
# An endpoint is technical transport metadata. The Hall does
|
|
382
|
+
# not show it or present it as a city to the sender.
|
|
383
|
+
"connected": True,
|
|
384
|
+
}
|
|
385
|
+
for fila in conexiones
|
|
386
|
+
],
|
|
387
|
+
"outbox": {"queued": int(salientes["queued"])},
|
|
388
|
+
}
|
|
389
|
+
finally:
|
|
390
|
+
db.close()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def resumen():
|
|
394
|
+
"""The cheap counter used by the persistent Hall rail."""
|
|
395
|
+
db = _conecta()
|
|
396
|
+
try:
|
|
397
|
+
ajustes = db.execute(
|
|
398
|
+
"""SELECT routing_mode, review_policy, router_profile
|
|
399
|
+
FROM reception_settings WHERE singleton = 1"""
|
|
400
|
+
).fetchone()
|
|
401
|
+
contadores = db.execute(
|
|
402
|
+
"SELECT pending_count, pending_bytes FROM reception_counters WHERE singleton = 1"
|
|
403
|
+
).fetchone()
|
|
404
|
+
reglas = db.execute(
|
|
405
|
+
"SELECT COUNT(*) AS count FROM reception_auto_rules WHERE enabled = 1"
|
|
406
|
+
).fetchone()
|
|
407
|
+
return {
|
|
408
|
+
"pending": int(contadores["pending_count"]),
|
|
409
|
+
"pendingBytes": int(contadores["pending_bytes"]),
|
|
410
|
+
"routingMode": ajustes["routing_mode"],
|
|
411
|
+
"reviewPolicy": ajustes["review_policy"],
|
|
412
|
+
"routerProfile": ajustes["router_profile"],
|
|
413
|
+
"autoAvailable": int(reglas["count"]) > 0,
|
|
414
|
+
}
|
|
415
|
+
finally:
|
|
416
|
+
db.close()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _prepara_decision(usuario, message_id, accion, destinos, motivo, actual):
|
|
420
|
+
"""Validate all human input before opening the serial decision transaction."""
|
|
421
|
+
message_id = str(message_id or "")
|
|
422
|
+
accion = str(accion or "")
|
|
423
|
+
motivo = " ".join(str(motivo or "").split())[:500]
|
|
424
|
+
if not MESSAGE_ID.fullmatch(message_id):
|
|
425
|
+
raise ReceptionError("invalid reception message id")
|
|
426
|
+
if accion not in ("route", "reject"):
|
|
427
|
+
raise ReceptionError("action must be route or reject")
|
|
428
|
+
if accion == "reject" and not motivo:
|
|
429
|
+
raise ReceptionError("write a reason before rejecting the message")
|
|
430
|
+
ciudades = {c["id"]: c for c in _ciudades(usuario, actual)}
|
|
431
|
+
elegidas = list(dict.fromkeys(str(x) for x in (destinos or []) if str(x)))
|
|
432
|
+
if accion == "route":
|
|
433
|
+
if not elegidas or len(elegidas) > MAX_DESTINATIONS:
|
|
434
|
+
raise ReceptionError("choose between 1 and 32 destination cities")
|
|
435
|
+
desconocidas = [ident for ident in elegidas if ident not in ciudades]
|
|
436
|
+
if desconocidas:
|
|
437
|
+
raise ReceptionError("a destination is not one of your local cities")
|
|
438
|
+
return message_id, accion, motivo, ciudades, elegidas
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _valida_salida(connection_id, texto):
|
|
442
|
+
connection_id = str(connection_id or "")
|
|
443
|
+
texto = str(texto or "")
|
|
444
|
+
if not CONNECTION_ID.fullmatch(connection_id):
|
|
445
|
+
raise ReceptionError("invalid connection")
|
|
446
|
+
if not texto.strip():
|
|
447
|
+
raise ReceptionError("write a message before sending")
|
|
448
|
+
if len(texto.encode("utf-8")) > MAX_OUTBOX_BYTES:
|
|
449
|
+
raise ReceptionError("message is too large")
|
|
450
|
+
return connection_id, texto
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _normaliza_keyword(value):
|
|
454
|
+
return " ".join(
|
|
455
|
+
unicodedata.normalize("NFKC", str(value or "")).lower().split()
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _prepara_reglas_auto(ciudades, rules):
|
|
460
|
+
preparadas = []
|
|
461
|
+
vistas = set()
|
|
462
|
+
for raw in rules:
|
|
463
|
+
if not isinstance(raw, dict):
|
|
464
|
+
raise ReceptionError("invalid automatic rule")
|
|
465
|
+
ident = str(raw.get("city_id") or "")
|
|
466
|
+
if ident not in ciudades or ident in vistas:
|
|
467
|
+
raise ReceptionError("an automatic destination is not one of your local cities")
|
|
468
|
+
keywords = raw.get("keywords")
|
|
469
|
+
if isinstance(keywords, str):
|
|
470
|
+
keywords = re.split(r"[,\n]", keywords)
|
|
471
|
+
if not isinstance(keywords, list):
|
|
472
|
+
raise ReceptionError("automatic keywords must be a list")
|
|
473
|
+
normalizadas = list(dict.fromkeys(
|
|
474
|
+
keyword for keyword in (_normaliza_keyword(x) for x in keywords) if keyword
|
|
475
|
+
))
|
|
476
|
+
if not normalizadas:
|
|
477
|
+
continue
|
|
478
|
+
if len(normalizadas) > 20 or any(len(x) > 80 for x in normalizadas):
|
|
479
|
+
raise ReceptionError("use at most 20 short keywords per city")
|
|
480
|
+
vistas.add(ident)
|
|
481
|
+
preparadas.append((
|
|
482
|
+
str(uuid.uuid4()),
|
|
483
|
+
ident,
|
|
484
|
+
ciudades[ident]["address"],
|
|
485
|
+
json.dumps(normalizadas, ensure_ascii=False, separators=(",", ":")),
|
|
486
|
+
0,
|
|
487
|
+
))
|
|
488
|
+
return preparadas
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def configura(usuario, routing_mode, rules, actual=""):
|
|
492
|
+
"""Replace the closed Auto allowlist and switch modes atomically."""
|
|
493
|
+
routing_mode = str(routing_mode or "")
|
|
494
|
+
if routing_mode not in ("manual", "auto"):
|
|
495
|
+
raise ReceptionError("routing mode must be manual or auto")
|
|
496
|
+
if not isinstance(rules, list) or len(rules) > MAX_DESTINATIONS:
|
|
497
|
+
raise ReceptionError("automatic rules must be a list of at most 32 cities")
|
|
498
|
+
ciudades = {c["id"]: c for c in _ciudades(usuario, actual)}
|
|
499
|
+
preparadas = _prepara_reglas_auto(ciudades, rules)
|
|
500
|
+
if routing_mode == "auto" and not preparadas:
|
|
501
|
+
raise ReceptionError("add at least one city rule before enabling Auto")
|
|
502
|
+
ahora = _ahora()
|
|
503
|
+
db = _conecta()
|
|
504
|
+
comprometida = False
|
|
505
|
+
try:
|
|
506
|
+
db.execute("BEGIN IMMEDIATE")
|
|
507
|
+
db.execute("DELETE FROM reception_auto_rules")
|
|
508
|
+
db.executemany(
|
|
509
|
+
"""INSERT INTO reception_auto_rules (
|
|
510
|
+
rule_id, target_city_id, target_city_address, keywords_json,
|
|
511
|
+
priority, enabled, updated_at
|
|
512
|
+
) VALUES (?, ?, ?, ?, ?, 1, ?)""",
|
|
513
|
+
[(*regla, ahora) for regla in preparadas],
|
|
514
|
+
)
|
|
515
|
+
db.execute(
|
|
516
|
+
"""UPDATE reception_settings
|
|
517
|
+
SET routing_mode = ?, router_profile = ?, updated_at = ?
|
|
518
|
+
WHERE singleton = 1""",
|
|
519
|
+
(
|
|
520
|
+
routing_mode,
|
|
521
|
+
AUTO_ROUTER_PROFILE if preparadas else None,
|
|
522
|
+
ahora,
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
db.execute("COMMIT")
|
|
526
|
+
comprometida = True
|
|
527
|
+
return {
|
|
528
|
+
"ok": True,
|
|
529
|
+
"routingMode": routing_mode,
|
|
530
|
+
"autoAvailable": bool(preparadas),
|
|
531
|
+
"rules": len(preparadas),
|
|
532
|
+
}
|
|
533
|
+
finally:
|
|
534
|
+
if not comprometida:
|
|
535
|
+
try:
|
|
536
|
+
db.execute("ROLLBACK")
|
|
537
|
+
except sqlite3.Error:
|
|
538
|
+
pass
|
|
539
|
+
db.close()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def envia(connection_id, texto):
|
|
543
|
+
"""Durably queue one person-to-person message before reporting success."""
|
|
544
|
+
connection_id, texto = _valida_salida(connection_id, texto)
|
|
545
|
+
ahora = _ahora()
|
|
546
|
+
message_id = str(uuid.uuid4())
|
|
547
|
+
db = _conecta()
|
|
548
|
+
comprometida = False
|
|
549
|
+
try:
|
|
550
|
+
db.execute("BEGIN IMMEDIATE")
|
|
551
|
+
conexion = db.execute(
|
|
552
|
+
"""SELECT road_id FROM reception_connections
|
|
553
|
+
WHERE connection_id = ? AND status = 'active' LIMIT 1""",
|
|
554
|
+
(connection_id,),
|
|
555
|
+
).fetchone()
|
|
556
|
+
if not conexion:
|
|
557
|
+
raise ReceptionError("connection is not available on this computer")
|
|
558
|
+
db.execute(
|
|
559
|
+
"""INSERT INTO reception_outbox (
|
|
560
|
+
message_id, road_id, connection_id, kind, body, created_at
|
|
561
|
+
) VALUES (?, ?, ?, 'message', ?, ?)""",
|
|
562
|
+
(message_id, conexion["road_id"], connection_id, texto, ahora),
|
|
563
|
+
)
|
|
564
|
+
db.execute("COMMIT")
|
|
565
|
+
comprometida = True
|
|
566
|
+
return {
|
|
567
|
+
"ok": True,
|
|
568
|
+
"id": message_id,
|
|
569
|
+
"status": "queued",
|
|
570
|
+
"connectionId": connection_id,
|
|
571
|
+
}
|
|
572
|
+
finally:
|
|
573
|
+
if not comprometida:
|
|
574
|
+
try:
|
|
575
|
+
db.execute("ROLLBACK")
|
|
576
|
+
except sqlite3.Error:
|
|
577
|
+
pass
|
|
578
|
+
db.close()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def decide(usuario, message_id, accion, destinos=None, motivo="", actual=""):
|
|
582
|
+
"""Atomically reject one message or queue it for one or more owned cities."""
|
|
583
|
+
message_id, accion, motivo, ciudades, elegidas = _prepara_decision(
|
|
584
|
+
usuario, message_id, accion, destinos, motivo, actual
|
|
585
|
+
)
|
|
586
|
+
ahora = _ahora()
|
|
587
|
+
db = _conecta()
|
|
588
|
+
comprometida = False
|
|
589
|
+
try:
|
|
590
|
+
db.execute("BEGIN IMMEDIATE")
|
|
591
|
+
mensaje = db.execute(
|
|
592
|
+
"""SELECT state, message_kind, connection_id, road_id, remote_message_id
|
|
593
|
+
FROM reception_messages WHERE message_id = ?""",
|
|
594
|
+
(message_id,),
|
|
595
|
+
).fetchone()
|
|
596
|
+
if not mensaje:
|
|
597
|
+
raise ReceptionError("reception message not found")
|
|
598
|
+
if mensaje["state"] != "pending":
|
|
599
|
+
raise ReceptionError("that reception message has already been decided")
|
|
600
|
+
if accion == "reject":
|
|
601
|
+
respuesta_id = None
|
|
602
|
+
if (
|
|
603
|
+
mensaje["message_kind"] == "message"
|
|
604
|
+
and mensaje["connection_id"]
|
|
605
|
+
and mensaje["road_id"]
|
|
606
|
+
and CONNECTION_ID.fullmatch(str(mensaje["remote_message_id"] or ""))
|
|
607
|
+
):
|
|
608
|
+
respuesta_id = str(uuid.uuid4())
|
|
609
|
+
db.execute(
|
|
610
|
+
"""INSERT INTO reception_outbox (
|
|
611
|
+
message_id, road_id, connection_id, kind, body,
|
|
612
|
+
in_reply_to, created_at
|
|
613
|
+
) VALUES (?, ?, ?, 'rejection', ?, ?, ?)""",
|
|
614
|
+
(
|
|
615
|
+
respuesta_id,
|
|
616
|
+
mensaje["road_id"],
|
|
617
|
+
mensaje["connection_id"],
|
|
618
|
+
motivo,
|
|
619
|
+
mensaje["remote_message_id"],
|
|
620
|
+
ahora,
|
|
621
|
+
),
|
|
622
|
+
)
|
|
623
|
+
db.execute(
|
|
624
|
+
"""UPDATE reception_messages
|
|
625
|
+
SET state = 'rejected', body = NULL, decided_at = ?, decision_reason = ?
|
|
626
|
+
WHERE message_id = ? AND state = 'pending'""",
|
|
627
|
+
(ahora, motivo, message_id),
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
for ident in elegidas:
|
|
631
|
+
ciudad = ciudades[ident]
|
|
632
|
+
db.execute(
|
|
633
|
+
"""INSERT INTO reception_routes
|
|
634
|
+
(message_id, target_city_id, target_city_address, state,
|
|
635
|
+
approved_by, approved_at)
|
|
636
|
+
VALUES (?, ?, ?, 'queued', 'human', ?)""",
|
|
637
|
+
(message_id, ident, ciudad["address"], ahora),
|
|
638
|
+
)
|
|
639
|
+
db.execute(
|
|
640
|
+
"""UPDATE reception_messages
|
|
641
|
+
SET state = 'routed', decided_at = ?, decision_reason = NULL
|
|
642
|
+
WHERE message_id = ? AND state = 'pending'""",
|
|
643
|
+
(ahora, message_id),
|
|
644
|
+
)
|
|
645
|
+
if db.execute("SELECT changes()").fetchone()[0] != 1:
|
|
646
|
+
raise ReceptionError("reception decision conflict")
|
|
647
|
+
db.execute("COMMIT")
|
|
648
|
+
comprometida = True
|
|
649
|
+
return {
|
|
650
|
+
"ok": True,
|
|
651
|
+
"id": message_id,
|
|
652
|
+
"status": "rejected" if accion == "reject" else "routed",
|
|
653
|
+
"destinations": elegidas if accion == "route" else [],
|
|
654
|
+
"reason": motivo if accion == "reject" else None,
|
|
655
|
+
"responseQueued": bool(respuesta_id) if accion == "reject" else False,
|
|
656
|
+
}
|
|
657
|
+
except sqlite3.IntegrityError as error:
|
|
658
|
+
raise ReceptionError("reception decision conflict") from error
|
|
659
|
+
finally:
|
|
660
|
+
if not comprometida:
|
|
661
|
+
try:
|
|
662
|
+
db.execute("ROLLBACK")
|
|
663
|
+
except sqlite3.Error:
|
|
664
|
+
pass
|
|
665
|
+
db.close()
|