@innvisor/conny-ai 9.7.0

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 (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. package/verify_conversation_impl.py +48 -0
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """conny_persona_cli.py — Agency persona control CLI."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from datetime import datetime
9
+
10
+ import httpx
11
+
12
+ PERSONAS_DIR = Path("/home/ubuntu/conny/personas")
13
+ INSTANCES_DIR = Path("/home/ubuntu/conny-instances")
14
+ API_BASE = "http://localhost:8001"
15
+
16
+
17
+ def get_persona_path(instance_id: str) -> Path:
18
+ candidates = [
19
+ INSTANCES_DIR / instance_id / "personas" / "persona.yaml",
20
+ PERSONAS_DIR / instance_id / "persona.yaml",
21
+ PERSONAS_DIR / instance_id / "runtime_override.json",
22
+ ]
23
+ for c in candidates:
24
+ if c.exists():
25
+ return c
26
+ return PERSONAS_DIR / instance_id / "persona.yaml"
27
+
28
+
29
+ def _get_key():
30
+ return os.getenv("ADMIN_API_KEY", os.getenv("MASTER_API_KEY", "conny_master_2026_santiago"))
31
+
32
+
33
+ def cmd_list(args):
34
+ print("Available instances:")
35
+ if INSTANCES_DIR.exists():
36
+ for d in sorted(INSTANCES_DIR.iterdir()):
37
+ if d.is_dir() and (d / ".env").exists():
38
+ print(f" {d.name} (instance)")
39
+ if PERSONAS_DIR.exists():
40
+ for d in sorted(PERSONAS_DIR.iterdir()):
41
+ if d.is_dir():
42
+ print(f" {d.name} (persona)")
43
+
44
+
45
+ def cmd_show(args):
46
+ path = get_persona_path(args.instance)
47
+ if not path.exists():
48
+ print(f"No persona found for '{args.instance}'")
49
+ return
50
+ print(f"Persona: {args.instance} ({path})")
51
+ print("─" * 50)
52
+ print(path.read_text())
53
+
54
+
55
+ def cmd_set(args):
56
+ payload = {args.field: args.value}
57
+ if args.field == "forbidden_topics":
58
+ payload[args.field] = [t.strip() for t in args.value.split(",")]
59
+ try:
60
+ r = httpx.post(f"{API_BASE}/admin/{args.instance}/persona",
61
+ json=payload, headers={"X-Admin-Key": _get_key()}, timeout=10)
62
+ if r.status_code == 200:
63
+ print(f"✓ Set {args.field}={args.value} for {args.instance}")
64
+ else:
65
+ print(f"✗ Error: {r.status_code} — {r.text}")
66
+ except Exception as e:
67
+ print(f"✗ Connection error: {e}")
68
+
69
+
70
+ def cmd_test(args):
71
+ key = os.getenv("MASTER_API_KEY", "conny_master_2026_santiago")
72
+ test_messages = [
73
+ "Hola, quiero una cita",
74
+ "Cuánto cuesta la consulta?",
75
+ "Tienen disponibilidad mañana?",
76
+ "Gracias, me agendas a las 3pm",
77
+ "Chao, bendiciones",
78
+ ]
79
+ print(f"Testing persona for: {args.instance}")
80
+ print("─" * 50)
81
+ for msg in test_messages:
82
+ try:
83
+ r = httpx.post(f"{API_BASE}/test",
84
+ json={"message": msg, "chat_id": f"persona_test_{args.instance}"},
85
+ headers={"X-Master-Key": key}, timeout=30)
86
+ data = r.json()
87
+ print(f" [USER] {msg}")
88
+ print(f" [CONNY] {data.get('response', 'error')[:120]}")
89
+ print()
90
+ except Exception as e:
91
+ print(f" [ERROR] {e}")
92
+
93
+
94
+ def cmd_status(args):
95
+ try:
96
+ r = httpx.get(f"{API_BASE}/admin/{args.instance}/status",
97
+ headers={"X-Admin-Key": _get_key()}, timeout=10)
98
+ if r.status_code == 200:
99
+ data = r.json()
100
+ print(f"Instance: {data.get('instance_id')}")
101
+ print(f"Persona: {json.dumps(data.get('persona', {}), indent=2, ensure_ascii=False)}")
102
+ print(f"Gaps today: {data.get('gaps_today', 0)}")
103
+ else:
104
+ print(f"Error: {r.status_code}")
105
+ except Exception as e:
106
+ print(f"Connection error: {e}")
107
+
108
+
109
+ def cmd_export(args):
110
+ path = get_persona_path(args.instance)
111
+ if path.exists():
112
+ print(path.read_text())
113
+ else:
114
+ print(f"No persona found for {args.instance}", file=sys.stderr)
115
+ sys.exit(1)
116
+
117
+
118
+ def cmd_history(args):
119
+ path = get_persona_path(args.instance)
120
+ if path.exists():
121
+ print(f"Persona file: {path}")
122
+ print(f"Last modified: {datetime.fromtimestamp(path.stat().st_mtime).isoformat()}")
123
+ else:
124
+ print(f"No persona file found for {args.instance}")
125
+
126
+
127
+ def main():
128
+ parser = argparse.ArgumentParser(prog="conny persona", description="Agency persona control")
129
+ sub = parser.add_subparsers(dest="command")
130
+
131
+ sub.add_parser("list", help="List available instances")
132
+ p = sub.add_parser("show", help="Show persona config"); p.add_argument("instance")
133
+ p = sub.add_parser("set", help="Set a field"); p.add_argument("instance"); p.add_argument("field"); p.add_argument("value")
134
+ p = sub.add_parser("test", help="Dry-run test"); p.add_argument("instance")
135
+ p = sub.add_parser("status", help="Runtime status"); p.add_argument("instance")
136
+ p = sub.add_parser("export", help="Export persona"); p.add_argument("instance")
137
+ p = sub.add_parser("history", help="Change history"); p.add_argument("instance")
138
+
139
+ args = parser.parse_args()
140
+ if not args.command:
141
+ parser.print_help()
142
+ return
143
+
144
+ cmds = {"list": cmd_list, "show": cmd_show, "set": cmd_set, "test": cmd_test,
145
+ "status": cmd_status, "export": cmd_export, "history": cmd_history}
146
+ cmds[args.command](args)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ conny_router.py — Webhook handlers and command routing.
4
+ Extracted from conny.py for Phase 3 split.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import random
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import httpx
18
+
19
+ from conny_config import Config, DEMO_CMD_ALIASES, DEMO_COMMANDS, DEMO_HELP_FULL
20
+
21
+ log = logging.getLogger("conny.router")
22
+
23
+
24
+ # ── Platform Detection ─────────────────────────────────────────────────────────
25
+
26
+ def detect_incoming_platform(body: Dict[str, Any]) -> str:
27
+ """Detect which platform sent the message."""
28
+ if body.get("entry"):
29
+ entry = body["entry"][0] if body["entry"] else {}
30
+ changes = entry.get("changes", [{}])[0] if entry.get("changes") else {}
31
+ value = changes.get("value", {})
32
+ if value.get("messages"):
33
+ return "whatsapp_cloud"
34
+ if value.get("statuses"):
35
+ return "whatsapp_cloud"
36
+ if body.get("message") or body.get("edited_message"):
37
+ return "telegram"
38
+ if body.get("data", {}).get("key", {}).get("fromMe"):
39
+ return "evolution"
40
+ if body.get("iswa"):
41
+ return "whatsapp"
42
+ return "unknown"
43
+
44
+
45
+ # ── Command Detection ──────────────────────────────────────────────────────────
46
+
47
+ def detect_command(text: str) -> Optional[str]:
48
+ """Detect if text is a command (slash or natural language alias)."""
49
+ if not text:
50
+ return None
51
+ text_norm = text.lower().strip()
52
+
53
+ # Slash commands
54
+ if text_norm.startswith("/"):
55
+ return text_norm
56
+
57
+ # Natural language aliases
58
+ for alias, cmd in DEMO_CMD_ALIASES.items():
59
+ if text_norm == alias or text_norm == "/" + alias:
60
+ return "/" + cmd
61
+
62
+ return None
63
+
64
+
65
+ # ── Full Command Handler (moved from enqueue_message) ────────────────────────
66
+
67
+ def handle_command(
68
+ chat_id: str,
69
+ text: str,
70
+ demo_sessions: Dict[str, Any],
71
+ send_fn,
72
+ ) -> Optional[List[str]]:
73
+ """
74
+ Handle commands BEFORE session lookup.
75
+ Returns a list of response strings, or None if not a command.
76
+ """
77
+ cmd = text.strip()
78
+ if not cmd.startswith("/"):
79
+ return None
80
+
81
+ # /help — show all commands
82
+ if cmd in ("/help", "/ayuda", "/comandos"):
83
+ bn = demo_sessions.get(chat_id + "_name", "")
84
+ if bn:
85
+ return [DEMO_HELP_FULL]
86
+ return [
87
+ "Comandos: /help | /reset | /bot | /status | /memoria"
88
+ ]
89
+
90
+ # /reset
91
+ if cmd in ("/reset", "/reiniciar"):
92
+ keys_del = [k for k in list(demo_sessions) if k.startswith(chat_id + "_") and not k.endswith("_ts")]
93
+ for k in keys_del:
94
+ try:
95
+ del demo_sessions[k]
96
+ except Exception:
97
+ pass
98
+ return ["listo, sesión limpia ||| empezamos de nuevo"]
99
+
100
+ # /status
101
+ if cmd in ("/status", "/estado"):
102
+ bn = demo_sessions.get(chat_id + "_name", "")
103
+ return [f"Estado: {'demo activa' if bn else 'en onboarding'} ||| negocio: {bn or 'sin nombre'}"]
104
+
105
+ # /bot
106
+ if cmd in ("/bot", "/recepcionista"):
107
+ return ["modo recepcionista ||| háblame como cliente y te respondo en contexto"]
108
+
109
+ # /memoria
110
+ if cmd in ("/memoria",):
111
+ return ["no tengo memoria activa todavía ||| en la próxima versión lo tendre"]
112
+
113
+ return None
114
+
115
+
116
+ # ── Webhook Parser: Telegram ────────────────────────────────────────────────────
117
+
118
+ def parse_telegram_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
119
+ """Parse Telegram webhook body into standard message format."""
120
+ msg = body.get("message") or body.get("edited_message")
121
+ if not msg:
122
+ return None
123
+
124
+ chat = msg.get("chat") or {}
125
+ chat_id = str(chat.get("id", ""))
126
+ if not chat_id:
127
+ return None
128
+
129
+ voice = msg.get("voice") or msg.get("audio")
130
+ audio_id = voice.get("file_id") if voice else None
131
+
132
+ document = msg.get("document")
133
+ photos = msg.get("photo") or []
134
+ caption = msg.get("caption", "").strip()
135
+
136
+ attachments = []
137
+ if document:
138
+ attachments.append({
139
+ "kind": "document",
140
+ "platform": "telegram",
141
+ "file_id": document.get("file_id", ""),
142
+ "filename": document.get("file_name", "document.bin"),
143
+ "mime_type": document.get("mime_type", "application/octet-stream"),
144
+ "caption": caption,
145
+ })
146
+ if photos:
147
+ photo = photos[-1]
148
+ attachments.append({
149
+ "kind": "image",
150
+ "platform": "telegram",
151
+ "file_id": photo.get("file_id", ""),
152
+ "filename": f"telegram_photo_{photo.get('file_unique_id', 'image')}.jpg",
153
+ "mime_type": "image/jpeg",
154
+ "caption": caption,
155
+ })
156
+
157
+ text = msg.get("text", "").strip() or caption
158
+
159
+ return {
160
+ "chat_id": chat_id,
161
+ "text": text,
162
+ "audio_id": audio_id,
163
+ "attachments": attachments,
164
+ "platform": "telegram",
165
+ }
166
+
167
+
168
+ # ── Webhook Parser: WhatsApp Cloud ─────────────────────────────────────────────
169
+
170
+ def parse_whatsapp_cloud_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
171
+ """Parse WhatsApp Cloud API webhook body."""
172
+ try:
173
+ entry = body.get("entry", [{}])[0]
174
+ changes = entry.get("changes", [{}])[0]
175
+ value = changes.get("value", {})
176
+ msgs = value.get("messages", [])
177
+ if not msgs:
178
+ return None
179
+ msg = msgs[0]
180
+ chat_id = msg.get("from", "")
181
+ msg_type = msg.get("type", "text")
182
+
183
+ attachments = []
184
+ text = None
185
+ audio_id = None
186
+
187
+ if msg_type == "text":
188
+ text = msg.get("text", {}).get("body", "").strip()
189
+ elif msg_type in ("audio", "voice"):
190
+ audio_id = msg.get("audio", msg.get("voice", {})).get("id", "")
191
+ elif msg_type == "image":
192
+ attachments.append({
193
+ "kind": "image",
194
+ "platform": "whatsapp_cloud",
195
+ "media_id": msg.get("image", {}).get("id", ""),
196
+ "filename": f"wa_cloud_image_{msg.get('id', 'image')}.jpg",
197
+ "mime_type": msg.get("image", {}).get("mime_type", "image/jpeg"),
198
+ "caption": msg.get("image", {}).get("caption", ""),
199
+ })
200
+ text = msg.get("image", {}).get("caption", "").strip()
201
+ elif msg_type == "document":
202
+ attachments.append({
203
+ "kind": "document",
204
+ "platform": "whatsapp_cloud",
205
+ "media_id": msg.get("document", {}).get("id", ""),
206
+ "filename": msg.get("document", {}).get("filename", f"wa_cloud_{msg.get('id', 'document')}"),
207
+ "mime_type": msg.get("document", {}).get("mime_type", "application/octet-stream"),
208
+ "caption": msg.get("document", {}).get("caption", ""),
209
+ })
210
+ text = msg.get("document", {}).get("caption", "").strip()
211
+
212
+ return {
213
+ "chat_id": chat_id,
214
+ "text": text,
215
+ "audio_id": audio_id,
216
+ "attachments": attachments,
217
+ "platform": "whatsapp_cloud",
218
+ }
219
+ except (IndexError, KeyError, TypeError):
220
+ return None
221
+
222
+
223
+ # ── Webhook Parser: WhatsApp Bridge ───────────────────────────────────────────
224
+
225
+ def parse_whatsapp_bridge_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
226
+ """Parse WhatsApp Bridge (Baileys) webhook body."""
227
+ try:
228
+ chat_id = body.get("key", {}).get("remoteJid", "")
229
+ if not chat_id:
230
+ return None
231
+ if chat_id.endswith("@g.us"):
232
+ return None # Skip group chats
233
+
234
+ attachments = []
235
+ text = body.get("message", {}).get("conversationMessage", {}).get("conversation", "").strip()
236
+
237
+ if body.get("isImage") and body.get("imageBase64"):
238
+ attachments.append({
239
+ "kind": "image",
240
+ "platform": "whatsapp",
241
+ "filename": f"wa_bridge_{body.get('messageId', 'image')}.jpg",
242
+ "mime_type": body.get("imageMime", "image/jpeg"),
243
+ "caption": text or "",
244
+ "base64": body.get("imageBase64", ""),
245
+ })
246
+ text = ""
247
+
248
+ if body.get("isDocument") and body.get("docBase64"):
249
+ attachments.append({
250
+ "kind": "document",
251
+ "platform": "whatsapp",
252
+ "filename": body.get("docName", f"wa_doc_{body.get('messageId', 'doc')}"),
253
+ "mime_type": body.get("docMime", "application/octet-stream"),
254
+ "caption": text or "",
255
+ "base64": body.get("docBase64", ""),
256
+ })
257
+ text = ""
258
+
259
+ audio_id = None
260
+ if body.get("isAudio") and body.get("audioBase64"):
261
+ b64_mime = body.get("audioMime", "audio/ogg")
262
+ audio_id = f"wa_b64:{b64_mime}:{body['audioBase64']}"
263
+
264
+ return {
265
+ "chat_id": chat_id,
266
+ "text": text,
267
+ "audio_id": audio_id,
268
+ "attachments": attachments,
269
+ "platform": "whatsapp",
270
+ }
271
+ except Exception:
272
+ return None
273
+
274
+
275
+ # ── Full Webhook Parser ─────────────────────────────────────────────────────────
276
+
277
+ def parse_webhook(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
278
+ """Parse any webhook body into standard format."""
279
+ platform = detect_incoming_platform(body)
280
+
281
+ if platform == "telegram":
282
+ return parse_telegram_message(body)
283
+ elif platform == "whatsapp_cloud":
284
+ return parse_whatsapp_cloud_message(body)
285
+ elif platform == "whatsapp":
286
+ return parse_whatsapp_bridge_message(body)
287
+ elif platform == "evolution":
288
+ try:
289
+ data = body.get("data", {})
290
+ key = data.get("key", {})
291
+ if key.get("fromMe", False):
292
+ return None
293
+ chat_id = key.get("remoteJid", "")
294
+ msg_data = data.get("message", {})
295
+ conv = msg_data.get("conversationMessage", {}).get("conversation", "").strip()
296
+ ext = msg_data.get("extendedTextMessage", {})
297
+ text = conv or ext.get("text", "").strip() or ""
298
+ return {
299
+ "chat_id": chat_id,
300
+ "text": text,
301
+ "audio_id": None,
302
+ "attachments": [],
303
+ "platform": "evolution",
304
+ }
305
+ except Exception:
306
+ return None
307
+
308
+ return None