# Piloto del webhook Jira FAC → plataforma (29-sep-2026). Autorizado por Pedro # (vía Johann). Cada escritura en Jira es explícita, una por subcomando: # # url → lee la URL pública del túnel ngrok local (solo lectura) # registrar → POST /rest/webhooks/1.0/webhook (ESCRIBE: 1 webhook de sitio) # crear-ticket → POST /rest/api/3/issue (ESCRIBE: 1 ticket de prueba en FAC) # crear-cotizacion [CLIENTE] → POST /rest/servicedeskapi/request (ESCRIBE: 1 ticket de # prueba COMPLETO por el formulario "Cotización Bind", el que sí # alcanza para armar la vista previa; default ACUNTIA, S.A.U.) # vista-previa CLAVE [paputec] → espera el resultado de la vista previa automática # del ticket y lo muestra con su dry-run (solo lectura) # esperar CLAVE [paputec] → espera la detección en la BD local o, con "paputec", # en la de producción por ssh (solo lectura) # borrar-ticket CLAVE → DELETE /rest/api/3/issue/CLAVE (ESCRIBE: borra el ticket de prueba) # borrar-webhook → DELETE del webhook registrado (reversa total) # mover URL_BASE → PUT del webhook a otra URL base (ESCRIBE), p. ej. # https://paputec.mx/balam (el receptor vive en /api/jira/webhook) # estado → webhooks de sitio actuales (solo lectura) # # Estado del piloto: webhook de sitio id 1 registrado el 29-sep apuntando al # túnel ngrok de Johann; prueba FAC-169 creada, detectada y borrada. El 30-sep # se movió a https://paputec.mx/balam (solo HMAC, secreto nuevo). # # El token de Jira y el secreto del webhook nunca se imprimen ni se versionan: # token → propuesta/token-jira.txt (gitignoreado) o $JIRA_TOKEN_FILE # secreto → $JIRA_WEBHOOK_SECRET_FILE (default: ~/jira-webhook-secret.txt) import base64, json, os, subprocess, sys, time, urllib.request, urllib.error SITE = "https://balam-jsm-temp.atlassian.net" EMAIL = "pedro.ayala@balamtalentoestrategico.com" PEDRO = "712020:0cde8558-7794-468c-9e2c-73dfe63bd57b" REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOME = os.path.expanduser("~") TOKEN = open(os.environ.get("JIRA_TOKEN_FILE", os.path.join(REPO, "propuesta", "token-jira.txt")), encoding="utf-8").read().strip() SECRETO = open(os.environ.get("JIRA_WEBHOOK_SECRET_FILE", os.path.join(HOME, "jira-webhook-secret.txt")), encoding="utf-8").read().strip() AUTH = "Basic " + base64.b64encode(f"{EMAIL}:{TOKEN}".encode()).decode() ESTADO = os.environ.get("JIRA_WEBHOOK_ESTADO", os.path.join(HOME, "jira-webhook-estado.json")) def jira(method, path, body=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(SITE + path, data=data, method=method, headers={ "Authorization": AUTH, "Accept": "application/json", "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read().decode("utf-8") return r.status, (json.loads(raw) if raw else None) except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace")[:500] def estado(**cambios): s = json.load(open(ESTADO, encoding="utf-8")) if os.path.exists(ESTADO) else {} s.update(cambios) json.dump(s, open(ESTADO, "w", encoding="utf-8"), indent=1) return s def url_publica(): for puerto in (4040, 4041, 4042): try: with urllib.request.urlopen(f"http://127.0.0.1:{puerto}/api/tunnels", timeout=3) as r: for t in json.loads(r.read().decode())["tunnels"]: if t["public_url"].startswith("https://") and "5079" in t["config"]["addr"]: return t["public_url"] except Exception: pass return None def sql(consulta, destino="local"): # Superusuario del docker-compose (local o paputec): ve la tabla pese a RLS. if destino == "paputec": # La consulta viaja por stdin: sin comillas que escapar a través de ssh + sudo. out = subprocess.run(["ssh", "-o", "BatchMode=yes", "johann@paputec.mx", "sudo docker exec -i balam-postgres psql -U balam -d balam -At -F ' | '"], input=consulta, capture_output=True, text=True, encoding="utf-8") else: out = subprocess.run(["docker", "exec", "balam-postgres-dev", "psql", "-U", "balam", "-d", "balam", "-At", "-F", " | ", "-c", consulta], capture_output=True, text=True, encoding="utf-8") return out.stdout.strip() or out.stderr.strip() cmd = sys.argv[1] if len(sys.argv) > 1 else "estado" if cmd == "url": print(url_publica() or "Sin túnel ngrok hacia 5079.") elif cmd == "registrar": base = url_publica() if not base: sys.exit("Sin túnel ngrok hacia 5079: no se registra nada.") cuerpo = { "name": "Balam plataforma · FAC ticket creado (piloto 29-sep)", "url": f"{base}/api/jira/webhook?token={SECRETO}", "events": ["jira:issue_created"], "filters": {"issue-related-events-section": "project = FAC"}, "excludeBody": False, "secret": SECRETO, } st, r = jira("POST", "/rest/webhooks/1.0/webhook", cuerpo) if st == 400 and "secret" in str(r).lower(): cuerpo.pop("secret") st, r = jira("POST", "/rest/webhooks/1.0/webhook", cuerpo) if st in (200, 201) and isinstance(r, dict): wid = r.get("self", "").rstrip("/").split("/")[-1] estado(webhook_id=wid, webhook_url_base=base, registrado=time.strftime("%Y-%m-%d %H:%M:%S"), con_secreto_hmac="secret" in cuerpo) print(f"[{st}] webhook registrado · id={wid} · eventos={r.get('events')} · filtro={r.get('filters')} · " f"secreto HMAC enviado={'secret' in cuerpo}") else: print(f"[{st}] {r}") elif cmd == "crear-ticket": adf = {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": f"Ticket de PRUEBA de la plataforma Balam ({time.strftime('%d/%m/%Y')}), autorizado por Pedro Ayala, para validar " "que la plataforma detecta la creación de tickets por webhook. Se elimina en cuanto se confirma."}]}]} st, r = jira("POST", "/rest/api/3/issue", {"fields": { "project": {"key": "FAC"}, "issuetype": {"id": "10073"}, "summary": "[PRUEBA PLATAFORMA BALAM] Detección por webhook — se borra en minutos", "description": adf, "assignee": {"accountId": PEDRO}}}) if st == 201: estado(ticket=r["key"], ticket_id=r["id"], creado=time.strftime("%Y-%m-%d %H:%M:%S")) print(f"[201] ticket de prueba creado: {r['key']} (id {r['id']})") else: print(f"[{st}] {r}") elif cmd == "crear-cotizacion": # Formulario "Cotización Bind" (service desk 35, request type 1196): todos sus # campos son obligatorios. Datos de prueba fijos y pequeños; el reporter es # el dueño del token (Pedro), que recibe el correo de confirmación. cliente = sys.argv[2] if len(sys.argv) > 2 else "ACUNTIA, S.A.U." vence = time.strftime("%Y-%m-%d", time.localtime(time.time() + 7 * 86400)) st, r = jira("POST", "/rest/servicedeskapi/request", { "serviceDeskId": "35", "requestTypeId": "1196", "requestFieldValues": { "summary": "[PRUEBA PLATAFORMA BALAM] Vista previa automática de cotización — se borra en minutos", "description": f"Ticket de PRUEBA de la plataforma Balam ({time.strftime('%d/%m/%Y')}), autorizado por Johann, " "para validar la vista previa automática de la cotización. Se elimina en cuanto se confirma.", "customfield_11423": {"value": "Balam"}, # Empresa origen "customfield_11598": {"value": cliente}, # Cliente "customfield_11599": {"value": "Servicios Profesionales Generales"}, # Servicio/Producto "customfield_11600": 1, # Cantidad "customfield_10053": {"value": "MXN"}, # Tipo de Moneda "customfield_11556": 1234, # Monto sin IVA "customfield_11601": {"value": "16%"}, # IVA "customfield_11597": vence, # Fecha de vencimiento }}) if st == 201 and isinstance(r, dict): estado(ticket=r["issueKey"], ticket_id=r["issueId"], creado=time.strftime("%Y-%m-%d %H:%M:%S")) print(f"[201] ticket de prueba completo creado: {r['issueKey']} (id {r['issueId']}) · cliente {cliente}") else: print(f"[{st}] {r}") elif cmd == "vista-previa": clave = sys.argv[2] destino = sys.argv[3] if len(sys.argv) > 3 else "local" for i in range(60): fila = sql(f"""SELECT e."Id", e."IssueKey", e."Verified", e."AuthMethod", round(extract(epoch from (e."ReceivedAtUtc"-e."JiraTimestampUtc"))::numeric,2), e."PreviewStatus", round(extract(epoch from (e."PreviewAtUtc"-e."JiraTimestampUtc"))::numeric,2), coalesce(e."PreviewOperationId"::text,''), coalesce(e."PreviewDetail",'') FROM "JiraTicketEvents" e WHERE e."IssueKey"='{clave}' AND e."PreviewStatus" IS NOT NULL""", destino) if fila and "|" in fila: print(f"VISTA PREVIA tras ~{i}s de espera: id | llave | verificado | auth | detección s | estado | lista a los s | operación | detalle") print(" " + fila) op = fila.split(" | ")[7].strip() if op: print(" dry-run guardado (WriteOperations): estatus | tipo | autor | confirmada") print(" " + sql(f"""SELECT "Status", "Type", "CreatedBy", "ConfirmedAtUtc" IS NOT NULL FROM "WriteOperations" WHERE "Id"={int(op)}""", destino)) print(sql(f"""SELECT "PayloadJson" FROM "WriteOperations" WHERE "Id"={int(op)}""", destino)) break time.sleep(1) else: print(f"Sin vista previa de {clave} en 60 s.") elif cmd == "esperar": clave = sys.argv[2] destino = sys.argv[3] if len(sys.argv) > 3 else "local" for i in range(60): fila = sql(f"""SELECT "Id","IssueKey","Verified","AuthMethod","JiraStatus", to_char("JiraTimestampUtc" AT TIME ZONE 'America/Mexico_City','HH24:MI:SS.MS'), to_char("ReceivedAtUtc" AT TIME ZONE 'America/Mexico_City','HH24:MI:SS.MS'), round(extract(epoch from ("ReceivedAtUtc"-"JiraTimestampUtc"))::numeric,2), coalesce("Error",'') FROM "JiraTicketEvents" WHERE "IssueKey"='{clave}'""", destino) if fila and "|" in fila: print(f"DETECTADO tras ~{i}s de espera: id | llave | verificado | auth | estatus Jira | emitido (MX) | recibido (MX) | retraso s | error") print(" " + fila) break time.sleep(1) else: print(f"Sin detección de {clave} en 60 s.") elif cmd == "borrar-ticket": clave = sys.argv[2] st, r = jira("DELETE", f"/rest/api/3/issue/{clave}") st2, _ = jira("GET", f"/rest/api/3/issue/{clave}") estado(ticket_borrado=clave if st == 204 else None, borrado=time.strftime("%Y-%m-%d %H:%M:%S")) print(f"[{st}] DELETE {clave} · verificación GET → {st2} (404 = ya no existe)") elif cmd == "borrar-webhook": wid = estado().get("webhook_id") st, r = jira("DELETE", f"/rest/webhooks/1.0/webhook/{wid}") print(f"[{st}] DELETE webhook {wid}") elif cmd == "mover": # Destino estable (paputec): solo HMAC, sin el secreto en la URL. base = sys.argv[2].rstrip("/") wid = estado().get("webhook_id") or "1" st, r = jira("PUT", f"/rest/webhooks/1.0/webhook/{wid}", { "name": "Balam plataforma · FAC ticket creado", "url": f"{base}/api/jira/webhook", "events": ["jira:issue_created"], "filters": {"issue-related-events-section": "project = FAC"}, "excludeBody": False, "secret": SECRETO, }) if st in (200, 201): estado(webhook_id=wid, webhook_url_base=base, movido=time.strftime("%Y-%m-%d %H:%M:%S")) print(f"[{st}] webhook {wid} → {base}/api/jira/webhook" + ("" if st in (200, 201) else f" · {r}")) elif cmd == "estado": st, r = jira("GET", "/rest/webhooks/1.0/webhook") print(f"[{st}] webhooks de sitio: {len(r) if isinstance(r, list) else r}") for w in r if isinstance(r, list) else []: print(f" - {w.get('name')} · habilitado={w.get('enabled')} · eventos={w.get('events')} · filtro={w.get('filters')}") print("estado local:", json.dumps(estado(), ensure_ascii=False))