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.
Files changed (98) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +240 -11
  3. package/README.md +238 -11
  4. package/benchmarks/latency/fake-claude-cli.mjs +2 -1
  5. package/benchmarks/reception/README.md +16 -0
  6. package/benchmarks/reception/run.mjs +84 -0
  7. package/bin/agents-city.js +1 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +117 -18
  10. package/bin/navegador.mjs +170 -1
  11. package/bin/serve.py +201 -101
  12. package/bin/test +15 -3
  13. package/bin/test-arnes.py +192 -0
  14. package/bin/test-cage.py +9 -0
  15. package/bin/test-channel.py +178 -12
  16. package/bin/test-claude-runtime.py +132 -4
  17. package/bin/test-connect-client.mjs +609 -0
  18. package/bin/test-connect.py +52 -0
  19. package/bin/test-contracts.py +214 -0
  20. package/bin/test-diario.py +185 -0
  21. package/bin/test-doctor.py +31 -0
  22. package/bin/test-i18n.py +189 -0
  23. package/bin/test-navegador.py +61 -0
  24. package/bin/test-seat.py +78 -20
  25. package/bin/test-security.py +2 -0
  26. package/bin/test-serve.py +366 -0
  27. package/city/web/dist-hall/hall.js +789 -214
  28. package/city/web/src/bienvenida.ts +14 -13
  29. package/city/web/src/casa.ts +143 -56
  30. package/city/web/src/demo.ts +61 -31
  31. package/city/web/src/es.ts +187 -27
  32. package/city/web/src/explorador.ts +70 -43
  33. package/city/web/src/hall.ts +520 -103
  34. package/city/web/src/idioma.ts +21 -1
  35. package/city/web/src/motores.ts +48 -1
  36. package/city/web/src/vista.ts +58 -0
  37. package/demo/graba.py +5 -10
  38. package/docs/managed-connect.md +237 -0
  39. package/docs/security.md +41 -2
  40. package/package.json +8 -4
  41. package/plugin/.claude-plugin/plugin.json +1 -1
  42. package/plugin/channel/adapter-prompts.ts +10 -0
  43. package/plugin/channel/adapter.js +78 -31
  44. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  45. package/plugin/channel/bus.js +109 -65
  46. package/plugin/channel/bus.ts +11 -1
  47. package/plugin/channel/client.js +67 -30
  48. package/plugin/channel/delivery-queue.ts +160 -21
  49. package/plugin/channel/hub/remote-roads.ts +32 -1
  50. package/plugin/channel/hub/road-controller.ts +116 -16
  51. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  52. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  53. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  54. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  55. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  56. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  57. package/plugin/channel/local-hub.js +2080 -97
  58. package/plugin/channel/local-hub.ts +5 -4
  59. package/plugin/channel/managed-connect/bridge.ts +208 -0
  60. package/plugin/channel/managed-connect/cli.ts +416 -0
  61. package/plugin/channel/managed-connect/device.ts +15 -0
  62. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  63. package/plugin/channel/managed-connect/person-message.ts +74 -0
  64. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  65. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  66. package/plugin/channel/managed-connect/storage.ts +653 -0
  67. package/plugin/channel/managed-connect/transport.ts +87 -0
  68. package/plugin/channel/managed-connect-cli.js +4777 -0
  69. package/plugin/channel/managed-connect-cli.ts +7 -0
  70. package/plugin/channel/managed-connect-client.d.ts +261 -0
  71. package/plugin/channel/managed-connect-client.js +6572 -0
  72. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  73. package/plugin/channel/package-lock.json +123 -105
  74. package/plugin/channel/package.json +4 -4
  75. package/plugin/channel/protocol.ts +4 -0
  76. package/plugin/channel/reception.ts +896 -0
  77. package/plugin/channel/road-cli.ts +1 -1
  78. package/plugin/channel/runtime/arnes.json +189 -0
  79. package/plugin/channel/runtime/arnes.ts +47 -0
  80. package/plugin/channel/runtime/claude.ts +18 -0
  81. package/plugin/channel/runtime/codex-config.ts +51 -1
  82. package/plugin/channel/runtime/codex.ts +31 -11
  83. package/plugin/channel/runtime/kimi.ts +6 -4
  84. package/plugin/channel/runtime-files.ts +39 -5
  85. package/plugin/channel/runtime-gateway.js +426 -100
  86. package/plugin/channel/runtime-gateway.ts +12 -0
  87. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  88. package/plugin/scripts/arnes.py +271 -0
  89. package/plugin/scripts/busca.py +46 -10
  90. package/plugin/scripts/cage.py +5 -0
  91. package/plugin/scripts/city-session.sh +144 -28
  92. package/plugin/scripts/crecimiento.py +4 -1
  93. package/plugin/scripts/demos.py +58 -43
  94. package/plugin/scripts/desinstala.py +36 -21
  95. package/plugin/scripts/diario.py +119 -0
  96. package/plugin/scripts/doctor.py +90 -15
  97. package/plugin/scripts/read-card.py +48 -8
  98. 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()