rairo commited on
Commit
ccba1da
Β·
verified Β·
1 Parent(s): 5b7bd24

Upload main_fixed.py

Browse files
Files changed (1) hide show
  1. main_fixed.py +1359 -0
main_fixed.py ADDED
@@ -0,0 +1,1359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, make_response
2
+ import os
3
+ import logging
4
+ import re
5
+ import sys
6
+ import json
7
+ import uuid
8
+ import threading
9
+ from collections import OrderedDict
10
+ from time import time
11
+ from datetime import datetime, timedelta
12
+ from typing import Optional, Dict, Any, Tuple, List
13
+
14
+ from dotenv import load_dotenv
15
+ import assemblyai as aai
16
+ import requests
17
+ import dns.resolver
18
+ import socket
19
+
20
+ from utility import (
21
+ generateResponse,
22
+ parse_multiple_transactions,
23
+ parse_vision_sale_transactions,
24
+ process_image_and_generate_query,
25
+ parse_inventory_json,
26
+ format_inventory_message,
27
+ confirm_stock_in,
28
+ persist_temporary_transaction,
29
+ persist_pending_image,
30
+ retrieve_pending_image,
31
+ process_intent,
32
+ format_transaction_response,
33
+ detect_and_translate_input,
34
+ translate_output,
35
+ cap_for_tts,
36
+ check_expiry_nudge,
37
+ apply_price_override,
38
+ get_user_currency,
39
+ set_user_currency,
40
+ CURRENCY_PROMPT,
41
+ )
42
+ import whatsapp_client as wa_client
43
+
44
+ load_dotenv()
45
+
46
+ # ── Config ─────────────────────────────────────────────────────────────────────
47
+ IMGUR_CLIENT_ID = os.getenv("IMGUR_CLIENT_ID")
48
+ URL_IMGUR = "https://api.imgur.com/3/image"
49
+ HEADERS_IMGUR = {"Authorization": f"Client-ID {IMGUR_CLIENT_ID}"} if IMGUR_CLIENT_ID else {}
50
+
51
+ DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
52
+ DEEPGRAM_TTS_URL = "https://api.deepgram.com/v1/speak?model=aura-asteria-en"
53
+
54
+ # AssemblyAI
55
+ try:
56
+ aai.settings.api_key = os.environ["aai_key"]
57
+ transcriber = aai.Transcriber()
58
+ except KeyError:
59
+ transcriber = None
60
+ logging.warning("AAI_KEY not found β€” audio transcription disabled.")
61
+ except Exception as e:
62
+ transcriber = None
63
+ logging.error(f"Failed to initialise AssemblyAI: {e}")
64
+
65
+ # Firebase
66
+ import firebase_admin
67
+ from firebase_admin import credentials, firestore, storage
68
+
69
+ def init_firestore_from_env(env_var: str = "FIREBASE"):
70
+ try:
71
+ if firebase_admin._apps:
72
+ return firestore.client()
73
+ sa_json = os.environ[env_var]
74
+ sa_info = json.loads(sa_json)
75
+ cred = credentials.Certificate(sa_info)
76
+ bucket = os.environ.get("FIREBASE_STORAGE_BUCKET")
77
+ firebase_admin.initialize_app(cred, {"storageBucket": bucket})
78
+ return firestore.client()
79
+ except KeyError as e:
80
+ logging.error("%s env var not set", e); raise
81
+ except Exception as e:
82
+ logging.exception("Failed to initialise Firestore: %s", e); raise
83
+
84
+ db = init_firestore_from_env()
85
+
86
+ # Wire Firestore into whatsapp_client (kept for compat)
87
+ wa_client.set_db(db)
88
+
89
+ app = Flask(__name__)
90
+
91
+ # ── Logging ────────────────────────────────────────────────────────────────────
92
+ logging.basicConfig(
93
+ level=logging.INFO,
94
+ format="%(asctime)s - %(levelname)s - %(module)s.%(funcName)s - %(message)s",
95
+ stream=sys.stdout,
96
+ force=True,
97
+ )
98
+ logger = logging.getLogger(__name__)
99
+
100
+ # ── DNS setup ──────────────────────────────────────────────────────────────────
101
+ nameserver1 = os.getenv("nameserver1", "8.8.8.8")
102
+ nameserver2 = os.getenv("nameserver2", "8.8.4.4")
103
+
104
+ def setup_dns() -> None:
105
+ try:
106
+ resolver = dns.resolver.Resolver()
107
+ resolver.nameservers = [nameserver1, nameserver2]
108
+ overrides = {}
109
+
110
+ for host in ["graph.facebook.com"]:
111
+ try:
112
+ ip = str(resolver.resolve(host, "A")[0])
113
+ overrides[host] = ip
114
+ logger.info(f"DNS override: {host} -> {ip}")
115
+ except Exception as e:
116
+ logger.warning(f"Could not resolve {host}: {e}")
117
+
118
+ proxy_url = os.getenv("WHATSAPP_PROXY_URL", "").strip()
119
+ if proxy_url:
120
+ from urllib.parse import urlparse as _up
121
+ proxy_host = _up(proxy_url).hostname
122
+ if proxy_host and proxy_host not in overrides:
123
+ try:
124
+ ip = str(resolver.resolve(proxy_host, "A")[0])
125
+ overrides[proxy_host] = ip
126
+ logger.info(f"DNS override: {proxy_host} -> {ip}")
127
+ except Exception as e:
128
+ logger.warning(f"Could not resolve proxy host {proxy_host}: {e}")
129
+
130
+ if overrides:
131
+ wa_client.configure_session(overrides)
132
+ except Exception as e:
133
+ logger.warning(f"DNS setup failed: {e}")
134
+
135
+ setup_dns()
136
+
137
+ # ── Constants ──────────────────────────────────────────────────────────────────
138
+ VERIFY_TOKEN = os.environ.get("VERIFY_TOKEN", "30cca545-3838-48b2-80a7-9e43b1ae8ce4")
139
+ GREETING_PATTERN = re.compile(r'^\s*(hi|hello|hola|hey|greetings|sawubona)\b.*$', re.IGNORECASE)
140
+
141
+ # Known currency codes/symbols for reply detection
142
+ _KNOWN_CURRENCIES = {
143
+ "r": "R", "zar": "R", "rand": "R", "rands": "R",
144
+ "south africa rand": "R", "south african rand": "R",
145
+ "sa rand": "R", "sa rands": "R",
146
+ "usd": "USD", "$": "USD", "dollar": "USD", "dollars": "USD",
147
+ "us dollar": "USD", "us dollars": "USD", "american dollar": "USD",
148
+ "zwg": "ZWG", "zig": "ZWG", "zimbabwe gold": "ZWG",
149
+ "kes": "KES", "ksh": "KES", "shilling": "KES", "kenyan shilling": "KES",
150
+ "ngn": "NGN", "naira": "NGN", "nigerian naira": "NGN",
151
+ "ghs": "GHS", "cedi": "GHS", "ghana cedi": "GHS",
152
+ "eur": "EUR", "euro": "EUR", "euros": "EUR",
153
+ "gbp": "GBP", "pound": "GBP", "pounds": "GBP",
154
+ "mwk": "MWK", "kwacha": "MWK", "malawi kwacha": "MWK",
155
+ "tzs": "TZS", "tshs": "TZS", "tanzanian shilling": "TZS",
156
+ "ugx": "UGX", "uganda shilling": "UGX",
157
+ "zamk": "ZMW", "zmw": "ZMW", "zambian kwacha": "ZMW",
158
+ "bwp": "BWP", "pula": "BWP", "botswana pula": "BWP",
159
+ "mzn": "MZN", "metical": "MZN",
160
+ }
161
+
162
+ # Ordered phrase list for mid-sentence currency detection
163
+ _CURRENCY_PHRASES = [
164
+ ("south african rand", "R"), ("south africa rand", "R"),
165
+ ("sa rands", "R"), ("sa rand", "R"),
166
+ ("us dollars", "USD"), ("us dollar", "USD"), ("american dollar", "USD"),
167
+ ("zimbabwe gold", "ZWG"),
168
+ ("kenyan shilling", "KES"),
169
+ ("nigerian naira", "NGN"),
170
+ ("ghana cedi", "GHS"),
171
+ ("zambian kwacha", "ZMW"),
172
+ ("botswana pula", "BWP"),
173
+ ("tanzanian shilling", "TZS"),
174
+ ("malawi kwacha", "MWK"),
175
+ ("rands", "R"), ("rand", "R"),
176
+ ("dollars", "USD"), ("dollar", "USD"),
177
+ ("euros", "EUR"), ("euro", "EUR"),
178
+ ("pounds", "GBP"), ("pound", "GBP"),
179
+ ("kwacha", "MWK"),
180
+ ("pula", "BWP"),
181
+ ("shilling", "KES"),
182
+ ("naira", "NGN"),
183
+ ("cedi", "GHS"),
184
+ ("metical", "MZN"),
185
+ ]
186
+
187
+
188
+ def _parse_currency_reply(text: str) -> Optional[str]:
189
+ """
190
+ Detect currency from any natural language phrasing.
191
+ Handles: 'R', 'USD', 'Rands', 'South Africa Rands',
192
+ 'I would like to use South African Rand', 'Please use USD'.
193
+ """
194
+ t = text.strip().lower().rstrip(".")
195
+
196
+ # Direct lookup
197
+ if t in _KNOWN_CURRENCIES:
198
+ return _KNOWN_CURRENCIES[t]
199
+
200
+ # Phrase match anywhere in text (handles "please use south africa rands")
201
+ for phrase, code in _CURRENCY_PHRASES:
202
+ if phrase in t:
203
+ return code
204
+
205
+ # Single short token that looks like a currency code
206
+ if re.match(r'^[a-z$£€₦₡]{1,4}$', t):
207
+ return t.upper()
208
+
209
+ return None
210
+
211
+ def _store_context(db, mobile: str, role: str, text: str) -> None:
212
+ """
213
+ Store the last exchange in Firestore for short-term context.
214
+ Keeps only the last 3 user+bot pairs (6 messages).
215
+ role: "user" | "bot"
216
+ """
217
+ try:
218
+ col = db.collection("users").document(mobile).collection("context")
219
+ col.add({
220
+ "role": role,
221
+ "text": text[:500], # cap to avoid Firestore bloat
222
+ "ts": firestore.SERVER_TIMESTAMP,
223
+ })
224
+ # Prune to last 6 messages
225
+ docs = list(col.order_by("ts", direction=firestore.Query.DESCENDING).limit(20).get())
226
+ if len(docs) > 6:
227
+ for doc in docs[6:]:
228
+ doc.reference.delete()
229
+ except Exception as e:
230
+ logger.warning(f"_store_context: {e}")
231
+
232
+
233
+ def _load_context(db, mobile: str) -> List[Dict]:
234
+ """
235
+ Load last 3 user+bot exchanges as [{role, text}] oldest-first.
236
+ Used to resolve pronouns like 'it', 'that', 'this', 'them'.
237
+ """
238
+ try:
239
+ docs = db.collection("users").document(mobile).collection("context") .order_by("ts", direction=firestore.Query.DESCENDING) .limit(6).get()
240
+ return [{"role": d.to_dict()["role"], "text": d.to_dict()["text"]}
241
+ for d in reversed(docs)]
242
+ except Exception as e:
243
+ logger.warning(f"_load_context: {e}")
244
+ return []
245
+
246
+
247
+ # Pronoun patterns that signal the current message refers to something
248
+ # mentioned in a previous exchange
249
+ _PRONOUN_RE = re.compile(
250
+ r'\b(it|its|that|this|them|they|those|these|the same|same one|'
251
+ r'that one|this one|that transaction|this transaction|'
252
+ r'the item|the product|the sale|the loan|the expense)\b',
253
+ re.IGNORECASE
254
+ )
255
+
256
+
257
+ def _resolve_context(text: str, context: List[Dict]) -> str:
258
+ """
259
+ If the current message contains pronouns/references that need context,
260
+ prepend a brief context hint so the NLP can resolve them.
261
+ Only adds context if pronouns are detected β€” avoids polluting clean messages.
262
+ """
263
+ if not context or not _PRONOUN_RE.search(text):
264
+ return text
265
+
266
+ # Build a concise context hint from the last 3 exchanges
267
+ # Only include user messages (what they were talking about)
268
+ user_msgs = [c["text"] for c in context if c["role"] == "user"][-3:]
269
+ if not user_msgs:
270
+ return text
271
+
272
+ hint = "Recent context: " + " | ".join(user_msgs)
273
+ # Cap hint length
274
+ if len(hint) > 200:
275
+ hint = hint[:200] + "..."
276
+
277
+ return f"[{hint}]\n\nCurrent message: {text}"
278
+
279
+
280
+ def _parse_price_reply(text: str) -> Optional[Dict[str, float]]:
281
+ """
282
+ Parse a price reply like:
283
+ banana: 3.50
284
+ orange: 4
285
+ apple: 5.00
286
+ Returns {item_name: price} or None if not a price reply.
287
+ """
288
+ if text.strip().lower() == "skip":
289
+ return {} # Empty dict = skip signal
290
+
291
+ lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
292
+ prices = {}
293
+ for line in lines:
294
+ # Match "item: price" or "item - price" or "item 3.50"
295
+ match = re.match(r'^([a-zA-Z\s]+)[:\-]\s*\$?([0-9]+(?:\.[0-9]{1,2})?)$', line)
296
+ if match:
297
+ item = match.group(1).strip().lower()
298
+ price = float(match.group(2))
299
+ prices[item] = price
300
+
301
+ return prices if prices else None
302
+
303
+
304
+ def _apply_price_replies(db, mobile: str, prices: Dict[str, float]) -> str:
305
+ """
306
+ Update price_each on stock_batches for named items.
307
+ Also apply any pending_discounts for those items.
308
+ Returns confirmation message.
309
+ """
310
+ from utility import get_user_currency, apply_price_override
311
+ currency = get_user_currency(db, mobile) or ""
312
+ updated = []
313
+
314
+ for item_name, price in prices.items():
315
+ try:
316
+ # Update all unprice stock_batches for this item
317
+ batches = db.collection("users").document(mobile) .collection("stock_batches") .where("name", "==", item_name).get()
318
+ for batch in batches:
319
+ if batch.to_dict().get("price_each") is None:
320
+ batch.reference.update({"price_each": price})
321
+
322
+ # Check if there's a pending discount to apply
323
+ disc_ref = db.collection("users").document(mobile) .collection("pending_discounts").document(item_name)
324
+ disc_doc = disc_ref.get()
325
+ if disc_doc.exists:
326
+ disc_pct = disc_doc.to_dict().get("discount_pct", 0)
327
+ new_p = apply_price_override(db, mobile, item_name, price, disc_pct)
328
+ updated.append(f"*{item_name.title()}*: {currency}{price} ({disc_pct}% discount active β†’ {currency}{new_p})")
329
+ disc_ref.delete()
330
+ else:
331
+ updated.append(f"*{item_name.title()}*: {currency}{price}")
332
+ except Exception as e:
333
+ logger.warning(f"_apply_price_replies failed for {item_name}: {e}")
334
+
335
+ if not updated:
336
+ return "No matching stock items found to update."
337
+ body = "\n".join(f" {u}" for u in updated)
338
+ return "\U0001f4b0 Prices saved:\n" + body
339
+
340
+
341
+
342
+ def _looks_like_name(text: str) -> bool:
343
+ """
344
+ Heuristic: is this text a person's name?
345
+ Names: 2-4 words, each capitalised, no digits, no punctuation.
346
+ """
347
+ words = text.strip().split()
348
+ if not (2 <= len(words) <= 4):
349
+ return False
350
+ return all(
351
+ w[0].isupper() and w.isalpha()
352
+ for w in words
353
+ )
354
+
355
+
356
+ def _try_fill_missing_details(reply_text: str, transactions: list):
357
+ """
358
+ Try to fill missing details from a user reply.
359
+ Handles:
360
+ - Amounts: "total 45", "R45", "45 paid 50"
361
+ - Names (party/lender/customer): "Siyabonga Dlamini", "John Smith"
362
+ Returns (updated_transactions, still_missing_prompt) or None if not a fill reply.
363
+ """
364
+ text = reply_text.strip()
365
+
366
+ # Check if this looks like a name answer
367
+ is_name = _looks_like_name(text)
368
+
369
+ # Check for amounts
370
+ total_m = re.search(
371
+ r'(?:total|for|amount|price)?\s*[r$]?(\d+(?:\.\d{1,2})?)',
372
+ text.lower()
373
+ )
374
+ paid_m = re.search(
375
+ r'(?:paid|customer paid|received)\s*[r$]?(\d+(?:\.\d{1,2})?)',
376
+ text.lower()
377
+ )
378
+
379
+ # Neither a name nor an amount β€” not a fill reply
380
+ if not is_name and not total_m and not paid_m:
381
+ return None
382
+
383
+ for txn in transactions:
384
+ details = txn.get("details", {})
385
+ txn_type = txn.get("transaction_type", "").lower()
386
+
387
+ # Fill name into appropriate field
388
+ if is_name:
389
+ if txn_type in ("loan",):
390
+ details.setdefault("party", text)
391
+ elif txn_type == "sale":
392
+ details.setdefault("customer", text)
393
+ elif txn_type == "expense":
394
+ details.setdefault("party", text)
395
+ else:
396
+ details.setdefault("party", text)
397
+
398
+ # Fill amounts
399
+ current_amount = details.get("amount") or details.get("total")
400
+ amount_ok = False
401
+ if current_amount is not None:
402
+ try:
403
+ amount_ok = float(str(current_amount)) > 0
404
+ except Exception:
405
+ pass
406
+
407
+ if total_m and not amount_ok:
408
+ details["amount"] = float(total_m.group(1))
409
+ if paid_m:
410
+ details["amount_paid"] = float(paid_m.group(1))
411
+
412
+ txn["details"] = details
413
+
414
+ still_missing = _check_missing_details(transactions)
415
+ return transactions, still_missing
416
+
417
+
418
+ def _check_missing_details(transactions: list, currency: str = "?") -> Optional[str]:
419
+ """
420
+ Check each transaction for missing critical fields.
421
+ Returns a prompt string if anything is missing, None if complete.
422
+
423
+ Required by type:
424
+ sale : amount (total) β€” prompt if absent AND no item-level prices
425
+ stock_in : quantity per item (usually present); price optional (prompted after confirm)
426
+ expense : amount + category
427
+ asset : amount + description
428
+ loan : amount + party
429
+ """
430
+ prompts = []
431
+
432
+ for txn in transactions:
433
+ txn_type = txn.get("transaction_type", "").lower()
434
+ details = txn.get("details", {})
435
+ items = details.get("items", [])
436
+ amount = details.get("amount") or details.get("total")
437
+ cur = currency if currency and currency not in ("?", "null", "None") else ""
438
+
439
+ # Validate amount is a real number
440
+ amount_ok = False
441
+ if amount is not None:
442
+ try:
443
+ v = float(str(amount))
444
+ amount_ok = v > 0
445
+ except (ValueError, TypeError):
446
+ pass
447
+
448
+ if txn_type == "sale":
449
+ desc = details.get("description", "") or (
450
+ ", ".join(f"{it.get('quantity','')} {it.get('item','')}"
451
+ for it in items) if items else "items"
452
+ )
453
+ if not amount_ok:
454
+ amt_prompt = f"How much did you sell {desc} for in total?"
455
+ if not details.get("amount_paid"):
456
+ amt_prompt += f"\nReply: total {cur}[amount] and paid {cur}[amount]"
457
+ else:
458
+ amt_prompt += f"\nReply: total {cur}[amount]"
459
+ prompts.append(amt_prompt)
460
+
461
+ elif txn_type == "expense":
462
+ desc = details.get("description", "this expense")
463
+ if not amount_ok:
464
+ prompts.append(f"How much did {desc} cost? Reply: {cur}[amount]")
465
+ if not details.get("category"):
466
+ prompts.append(
467
+ "What category is this expense? "
468
+ "Reply: rent | transport | airtime | wages | packaging | other"
469
+ )
470
+
471
+ elif txn_type == "asset":
472
+ desc = details.get("description", "this asset")
473
+ if not amount_ok:
474
+ prompts.append(f"How much did {desc} cost? Reply: {cur}[amount]")
475
+
476
+ elif txn_type == "loan":
477
+ if not amount_ok:
478
+ prompts.append(f"What is the loan amount? Reply: {cur}[amount]")
479
+ if not details.get("party"):
480
+ prompts.append("Who is the lender/borrower? Reply with their name.")
481
+
482
+ if not prompts:
483
+ return None
484
+
485
+ lines = ["I need a few more details before saving:"]
486
+ for i, p in enumerate(prompts, 1):
487
+ lines.append(f"\n{i}. {p}")
488
+ return "\n".join(lines)
489
+
490
+
491
+ def _parse_price_reply(text: str) -> Optional[Dict[str, float]]:
492
+ """Parse price reply: 'banana: 3.50\norange: 4'"""
493
+ if text.strip().lower() == "skip":
494
+ return {}
495
+ lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
496
+ prices = {}
497
+ for line in lines:
498
+ match = re.match(r'^([a-zA-Z\s]+)[:\-]\s*\$?([0-9]+(?:\.[0-9]{1,2})?)$', line)
499
+ if match:
500
+ prices[match.group(1).strip().lower()] = float(match.group(2))
501
+ return prices if prices else None
502
+
503
+
504
+ def _apply_price_replies(db, mobile: str, prices: Dict[str, float]) -> str:
505
+ from utility import get_user_currency, apply_price_override
506
+ currency = get_user_currency(db, mobile) or ""
507
+ updated = []
508
+ for item_name, price in prices.items():
509
+ try:
510
+ batches = db.collection("users").document(mobile) .collection("stock_batches") .where("name", "==", item_name).get()
511
+ for batch in batches:
512
+ if batch.to_dict().get("price_each") is None:
513
+ batch.reference.update({"price_each": price})
514
+ disc_ref = db.collection("users").document(mobile) .collection("pending_discounts").document(item_name)
515
+ disc_doc = disc_ref.get()
516
+ if disc_doc.exists:
517
+ disc_pct = disc_doc.to_dict().get("discount_pct", 0)
518
+ new_p = apply_price_override(db, mobile, item_name, price, disc_pct)
519
+ updated.append(f"*{item_name.title()}*: {currency}{price} "
520
+ f"({disc_pct}% discount active β†’ {currency}{new_p})")
521
+ disc_ref.delete()
522
+ else:
523
+ updated.append(f"*{item_name.title()}*: {currency}{price}")
524
+ except Exception as e:
525
+ logger.warning(f"_apply_price_replies {item_name}: {e}")
526
+ if not updated:
527
+ return "No matching stock items found."
528
+ items_joined = "\n".join(f" {u}" for u in updated)
529
+ return "\U0001f4b0 Prices saved:\n" + items_joined
530
+
531
+ # ── Deduplication ──────────────────────────────────────────────────────────────
532
+ PROCESSED_MESSAGES_TTL_HOURS = 24
533
+
534
+ class MessageDeduplicator:
535
+ def __init__(self, ttl_hours=24, max_cache_size=10000, db_client=None):
536
+ self.ttl_seconds = ttl_hours * 3600
537
+ self.max_cache_size = max_cache_size
538
+ self.db_client = db_client
539
+ self.cache = OrderedDict()
540
+ self.lock = threading.RLock()
541
+ threading.Thread(target=self._periodic_cleanup, daemon=True).start()
542
+
543
+ def is_duplicate(self, message_id):
544
+ if not message_id: return False
545
+ with self.lock:
546
+ if message_id in self.cache:
547
+ self.cache.move_to_end(message_id)
548
+ return True
549
+ if self.db_client:
550
+ try:
551
+ doc = self.db_client.collection("processed_messages").document(message_id).get()
552
+ if doc.exists:
553
+ self.cache[message_id] = time()
554
+ if len(self.cache) > self.max_cache_size:
555
+ self.cache.popitem(last=False)
556
+ return True
557
+ except Exception as e:
558
+ logger.error(f"is_duplicate DB error: {e}")
559
+ self._mark_processed(message_id)
560
+ return False
561
+
562
+ def _mark_processed(self, message_id):
563
+ with self.lock:
564
+ self.cache[message_id] = time()
565
+ if len(self.cache) > self.max_cache_size:
566
+ self.cache.popitem(last=False)
567
+ if self.db_client:
568
+ try:
569
+ expiry = datetime.now() + timedelta(hours=self.ttl_seconds / 3600)
570
+ self.db_client.collection("processed_messages").document(message_id).set(
571
+ {"processed_at": firestore.SERVER_TIMESTAMP, "expires_at": expiry}
572
+ )
573
+ except Exception as e:
574
+ logger.error(f"_mark_processed DB error: {e}")
575
+
576
+ def _periodic_cleanup(self):
577
+ while True:
578
+ try:
579
+ with self.lock:
580
+ now = time()
581
+ expired = [mid for mid, ts in list(self.cache.items())
582
+ if now - ts > self.ttl_seconds]
583
+ for mid in expired:
584
+ self.cache.pop(mid, None)
585
+ threading.Event().wait(3600)
586
+ except Exception as e:
587
+ logger.error(f"cleanup thread error: {e}")
588
+ threading.Event().wait(300)
589
+
590
+ message_deduplicator = MessageDeduplicator(
591
+ ttl_hours=PROCESSED_MESSAGES_TTL_HOURS, db_client=db
592
+ )
593
+
594
+ def check_and_mark_processed(message_id: str) -> bool:
595
+ if not message_id:
596
+ logger.warning("Empty message ID")
597
+ return False
598
+ return message_deduplicator.is_duplicate(message_id)
599
+
600
+ # ── Auth ───────────────────────────────────────────────────────────────────────
601
+
602
+ def is_user_approved(mobile: str) -> Tuple[bool, Optional[Dict]]:
603
+ if not db:
604
+ logger.error("Firestore not available for auth")
605
+ return False, None
606
+ try:
607
+ normalized = mobile if mobile.startswith("+") else f"+{mobile}"
608
+ logger.info(f"AUTHORIZATION: Checking approval for mobile: '{mobile}'")
609
+ doc = db.collection("users").document(normalized).get()
610
+ if doc.exists:
611
+ data = doc.to_dict()
612
+ if data.get("status", "").lower() == "approved":
613
+ return True, data
614
+ return False, None
615
+ except Exception as e:
616
+ logger.error(f"is_user_approved error for {mobile}: {e}", exc_info=True)
617
+ return False, None
618
+
619
+ # ── Button senders ─────────────────────────────────────────────────────────────
620
+
621
+ def send_confirmation_buttons(mobile: str, message_summary: str,
622
+ is_variance: bool = False) -> None:
623
+ if is_variance:
624
+ buttons = [
625
+ {"reply": {"id": "confirm_resolved", "title": "βœ… Settled"}},
626
+ {"reply": {"id": "confirm_unresolved", "title": "⚠️ Pending"}},
627
+ {"reply": {"id": "cancel_transaction", "title": "❌ Cancel"}},
628
+ ]
629
+ else:
630
+ buttons = [
631
+ {"reply": {"id": "confirm_transaction", "title": "βœ… Confirm"}},
632
+ {"reply": {"id": "cancel_transaction", "title": "❌ Cancel"}},
633
+ ]
634
+ # Summary is pre-formatted by format_transaction_response β€” send directly
635
+ # WhatsApp button body max 1024 chars
636
+ body = message_summary[:1020] + "..." if len(message_summary) > 1024 else message_summary
637
+ wa_client.send_reply_buttons(recipient_id=mobile, body_text=body, button_data=buttons)
638
+
639
+
640
+ def send_image_intent_buttons(mobile: str) -> None:
641
+ """Ask vendor whether the image is stock-in or a sale β€” shown when no caption."""
642
+ wa_client.send_reply_buttons(
643
+ recipient_id=mobile,
644
+ body_text="What is this image for?",
645
+ button_data=[
646
+ {"reply": {"id": "image_stock_in", "title": "πŸ“¦ Stock In"}},
647
+ {"reply": {"id": "image_record_sale", "title": "πŸ’° Record Sale"}},
648
+ ],
649
+ )
650
+
651
+
652
+ def send_discount_buttons(mobile: str, item: Dict, idx: int) -> None:
653
+ """
654
+ Send a discount suggestion button per flagged item.
655
+ No prices shown β€” discount % only. Price set later by user.
656
+ """
657
+ name = item["name"]
658
+ disc = item["discount_pct"]
659
+ quality = item["quality"]
660
+ qty = item["quantity"]
661
+
662
+ icon = "πŸ”΄" if quality == "urgent_move" else "🟠"
663
+ body = (
664
+ f"{icon} *{name.title()}* β€” {qty} units\n"
665
+ f"Quality: {quality.replace('_', ' ')}\n"
666
+ f"Suggested discount: *{disc}% off* selling price\n\n"
667
+ f"Apply this discount for the next 24 hours?"
668
+ )
669
+ wa_client.send_reply_buttons(
670
+ recipient_id=mobile,
671
+ body_text=body,
672
+ button_data=[
673
+ {"reply": {"id": f"discount_confirm_{idx}_{name}", "title": "βœ… Apply Discount"}},
674
+ {"reply": {"id": f"discount_skip_{idx}_{name}", "title": "⏭️ Skip"}},
675
+ ],
676
+ )
677
+
678
+ # ── Interactive response handler ───────────────────────────────────────────────
679
+
680
+ def handle_interactive_response(mobile: str, button_id: str) -> None:
681
+ if not db:
682
+ wa_client.send_text_message(mobile, "Database unavailable β€” cannot process.")
683
+ return
684
+
685
+ # ── Stock in / Sale intent buttons ────────────────────────────────────────
686
+ if button_id in ("image_stock_in", "image_record_sale"):
687
+ image_bytes, caption = retrieve_pending_image(mobile, db)
688
+ if not image_bytes:
689
+ wa_client.send_text_message(mobile, "Couldn't find your image β€” please send it again.")
690
+ return
691
+
692
+ mode = "stock_in" if button_id == "image_stock_in" else "sale"
693
+ wa_client.send_text_message(mobile, "Analysing your image... πŸ”")
694
+ result = process_image_and_generate_query(image_bytes, caption, mode=mode)
695
+ _handle_vision_result(mobile, result, caption)
696
+ return
697
+
698
+ # ── Inventory confirm ─────────────────────────────────────────────────────
699
+ if button_id == "confirm_inventory":
700
+ inv_ref = db.collection("users").document(mobile) \
701
+ .collection("temp_inventory").document("pending")
702
+ inv_doc = inv_ref.get()
703
+ if not inv_doc.exists:
704
+ wa_client.send_text_message(mobile, "No pending inventory found.")
705
+ return
706
+ inventory_data = inv_doc.to_dict().get("inventory", {})
707
+ inv_ref.delete()
708
+ # Get user currency for price prompt
709
+ from utility import get_user_currency
710
+ currency = get_user_currency(db, mobile) or "?"
711
+ msg = confirm_stock_in(db, mobile, inventory_data, currency=currency)
712
+ wa_client.send_text_message(mobile, msg)
713
+
714
+ # Send discount suggestion buttons for quality-flagged items
715
+ _, discount_items = format_inventory_message(inventory_data)
716
+ for i, item in enumerate(discount_items):
717
+ send_discount_buttons(mobile, item, i)
718
+ return
719
+
720
+ # ── Discount buttons ──────────────────────────────────────────────────────
721
+ if button_id.startswith("discount_confirm_"):
722
+ # Format: discount_confirm_{idx}_{item_name}
723
+ parts = button_id.split("_", 3)
724
+ if len(parts) == 4:
725
+ item_name = parts[3]
726
+ inv_ref = db.collection("users").document(mobile) .collection("temp_inventory").document("discount_meta")
727
+ meta_doc = inv_ref.get()
728
+ disc = 0
729
+ if meta_doc.exists:
730
+ meta = meta_doc.to_dict()
731
+ disc = meta.get(item_name, {}).get("discount_pct", 20)
732
+
733
+ # Look up actual price from stock_batches
734
+ from utility import _lookup_item_price
735
+ price = _lookup_item_price(db, mobile, item_name)
736
+ if price:
737
+ new_p = apply_price_override(db, mobile, item_name, price, disc)
738
+ from utility import get_user_currency
739
+ cur = get_user_currency(db, mobile) or ""
740
+ wa_client.send_text_message(
741
+ mobile,
742
+ f"βœ… Discount applied: *{item_name.title()}* β†’ {cur}{new_p} for 24h ({disc}% off)."
743
+ )
744
+ else:
745
+ # No price in DB yet β€” store discount pct, apply when price is set
746
+ db.collection("users").document(mobile) .collection("pending_discounts").document(item_name).set({
747
+ "item_name": item_name,
748
+ "discount_pct": disc,
749
+ "created_at": firestore.SERVER_TIMESTAMP,
750
+ })
751
+ wa_client.send_text_message(
752
+ mobile,
753
+ f"βœ… {disc}% discount noted for *{item_name.title()}*. "
754
+ f"I'll apply it once you set the selling price."
755
+ )
756
+ return
757
+
758
+ if button_id.startswith("discount_skip_"):
759
+ parts = button_id.split("_", 3)
760
+ item_name = parts[3] if len(parts) == 4 else "item"
761
+ wa_client.send_text_message(mobile, f"⏭️ Skipped discount for {item_name.title()}.")
762
+ return
763
+
764
+ # ── Transaction confirm / cancel ──────────────────────────────────────────
765
+ doc_ref = db.collection("users").document(mobile) \
766
+ .collection("temp_transactions").document("pending")
767
+ try:
768
+ txn_doc = doc_ref.get()
769
+ if not txn_doc.exists:
770
+ wa_client.send_text_message(mobile, "No transaction waiting for confirmation.")
771
+ return
772
+
773
+ pending = txn_doc.to_dict().get("transactions", [])
774
+ if not pending:
775
+ wa_client.send_text_message(mobile, "Issue with pending transaction data.")
776
+ doc_ref.delete()
777
+ return
778
+
779
+ if button_id == "confirm_transaction":
780
+ result = process_intent(pending, mobile, force_settled=False)
781
+ elif button_id == "confirm_resolved":
782
+ result = process_intent(pending, mobile, force_settled=True)
783
+ elif button_id == "confirm_unresolved":
784
+ result = process_intent(pending, mobile, force_settled=False)
785
+ elif button_id == "confirm_reset":
786
+ result = process_intent(pending, mobile, force_settled=False)
787
+ elif button_id == "cancel_transaction":
788
+ result = "Transaction cancelled."
789
+ else:
790
+ result = "Unrecognised button."
791
+
792
+ doc_ref.delete()
793
+
794
+ # Handle TX_IDS tuple β€” send summary then IDs as separate messages
795
+ if isinstance(result, tuple) and len(result) == 3 and result[0] == "TX_IDS":
796
+ _, summary, tx_ids = result
797
+ wa_client.send_text_message(mobile, summary)
798
+ if tx_ids:
799
+ id_block = "\n".join(tx_ids)
800
+ label = "Transaction IDs" if len(tx_ids) > 1 else "Transaction ID"
801
+ wa_client.send_text_message(
802
+ mobile,
803
+ f"\U0001f194 *{label}* (tap to copy):\n{id_block}"
804
+ )
805
+
806
+ except Exception as e:
807
+ logger.error(f"handle_interactive_response error for {mobile}: {e}", exc_info=True)
808
+ wa_client.send_text_message(mobile, "Something went wrong handling your confirmation.")
809
+
810
+ # ── Vision result dispatcher ───────────────────────────────────────────────────
811
+
812
+ def _handle_vision_result(mobile: str, result: str, caption: Optional[str]) -> None:
813
+ """
814
+ Route vision output:
815
+ - INVENTORY_JSON prefix β†’ show count summary + store for confirmation
816
+ - Plain text β†’ send to NLP pipeline as transaction description
817
+ - File path β†’ upload as image
818
+ """
819
+ inventory_data = parse_inventory_json(result)
820
+
821
+ if inventory_data:
822
+ # Format and send inventory summary
823
+ summary, discount_items = format_inventory_message(inventory_data)
824
+ wa_client.send_text_message(mobile, summary)
825
+
826
+ # Store inventory + discount metadata for button confirmations
827
+ db.collection("users").document(mobile) \
828
+ .collection("temp_inventory").document("pending").set({
829
+ "inventory": inventory_data,
830
+ "created_at": firestore.SERVER_TIMESTAMP,
831
+ })
832
+
833
+ # Store discount metadata keyed by item name
834
+ if discount_items:
835
+ meta = {
836
+ item["name"]: {
837
+ "original_price": item["original_price"],
838
+ "discount_pct": item["discount_pct"],
839
+ "new_price": item["new_price"],
840
+ }
841
+ for item in discount_items
842
+ }
843
+ db.collection("users").document(mobile) \
844
+ .collection("temp_inventory").document("discount_meta").set(meta)
845
+
846
+ # Ask vendor to confirm stock-in
847
+ wa_client.send_reply_buttons(
848
+ recipient_id=mobile,
849
+ body_text="Save this as new stock?",
850
+ button_data=[
851
+ {"reply": {"id": "confirm_inventory", "title": "βœ… Save Stock"}},
852
+ {"reply": {"id": "cancel_transaction", "title": "❌ Cancel"}},
853
+ ],
854
+ )
855
+
856
+ # Send discount buttons for flagged items
857
+ for i, item in enumerate(discount_items):
858
+ send_discount_buttons(mobile, item, i)
859
+
860
+ elif result.startswith("Error:"):
861
+ wa_client.send_text_message(mobile, result)
862
+
863
+ elif os.path.isfile(result) and HEADERS_IMGUR:
864
+ # Chart file β€” upload to Imgur and send
865
+ try:
866
+ with open(result, "rb") as f:
867
+ resp = requests.post(URL_IMGUR, headers=HEADERS_IMGUR, files={"image": f})
868
+ resp.raise_for_status()
869
+ imgur_data = resp.json()
870
+ if imgur_data.get("success"):
871
+ wa_client.send_image_message(recipient_id=mobile,
872
+ image_url=imgur_data["data"]["link"])
873
+ os.remove(result)
874
+ return
875
+ except Exception as e:
876
+ logger.error(f"Imgur upload failed: {e}")
877
+ wa_client.send_text_message(mobile, result)
878
+ if os.path.exists(result): os.remove(result)
879
+
880
+ else:
881
+ # Sale mode: try direct per-item parser first (avoids NLP collapsing items)
882
+ currency = get_user_currency(db, mobile) or "?"
883
+ sale_txns = parse_vision_sale_transactions(result, currency=currency)
884
+
885
+ if sale_txns:
886
+ # Direct parsed β€” skip generateResponse, go straight to confirmation
887
+ if persist_temporary_transaction(sale_txns, mobile):
888
+ summary = format_transaction_response(sale_txns)
889
+ has_variance = any(
890
+ "amount_paid" in t.get("details", {}) for t in sale_txns
891
+ )
892
+ send_confirmation_buttons(mobile, summary, is_variance=has_variance)
893
+ else:
894
+ wa_client.send_text_message(mobile, "Sorry, could not save your sale.")
895
+ else:
896
+ # Fallback β€” plain text through NLP pipeline
897
+ process_text_message(result, mobile)
898
+
899
+
900
+
901
+ def _extract_clean_message(message_text: Optional[str]) -> str:
902
+ """
903
+ Clean WhatsApp text before sending it into the NLP pipeline.
904
+
905
+ Main purpose:
906
+ - Prevent crashes when users paste/forward the bot's Transaction ID block.
907
+ - Remove WhatsApp markdown around transaction IDs.
908
+ - Preserve ordinary business messages unchanged.
909
+
910
+ Examples handled:
911
+ "πŸ†” *Transaction ID* (tap to copy):\nABC123"
912
+ "Reverse this πŸ†” *Transaction ID* (tap to copy):\nABC123"
913
+ "Transaction IDs:\nABC123\nDEF456"
914
+ """
915
+ if message_text is None:
916
+ return ""
917
+
918
+ text = str(message_text).strip()
919
+ if not text:
920
+ return ""
921
+
922
+ # Remove invisible/control characters that sometimes arrive from WhatsApp copy/paste.
923
+ text = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", text)
924
+
925
+ # Only do aggressive cleanup for transaction-ID style messages.
926
+ tx_label_re = re.compile(r"transaction\s+ids?", re.IGNORECASE)
927
+ if not tx_label_re.search(text):
928
+ return text
929
+
930
+ # Capture likely transaction IDs. Keep this broad because the utility layer may
931
+ # generate IDs with dashes/underscores, not only UUIDs.
932
+ tx_ids = re.findall(r"\b[A-Za-z0-9][A-Za-z0-9_-]{5,}\b", text)
933
+
934
+ # Remove common label/UX text, icons and WhatsApp markdown.
935
+ cleaned = text
936
+ cleaned = re.sub(r"[πŸ†”*]", "", cleaned)
937
+ cleaned = re.sub(r"transaction\s+ids?", "", cleaned, flags=re.IGNORECASE)
938
+ cleaned = re.sub(r"\(?\s*tap\s+to\s+copy\s*\)?", "", cleaned, flags=re.IGNORECASE)
939
+ cleaned = re.sub(r"[:\-]+", " ", cleaned)
940
+
941
+ # Remove IDs from the instruction portion, then add them back cleanly once.
942
+ instruction = cleaned
943
+ for tx_id in tx_ids:
944
+ instruction = instruction.replace(tx_id, " ")
945
+ instruction = re.sub(r"\s+", " ", instruction).strip()
946
+
947
+ if tx_ids:
948
+ unique_ids = list(dict.fromkeys(tx_ids))
949
+ id_text = " ".join(unique_ids)
950
+ return f"{instruction} {id_text}".strip() if instruction else id_text
951
+
952
+ # Fallback: return a readable cleaned message instead of failing.
953
+ return re.sub(r"\s+", " ", cleaned).strip() or text
954
+
955
+ # ── Text message processor ─────────────────────────────────────────────────────
956
+
957
+ def process_text_message(message_text: str, mobile: str,
958
+ user_settings: Optional[Dict] = None) -> Optional[str]:
959
+ logger.info(f"Processing text message from {mobile}: '{message_text}'")
960
+
961
+ # Extract raw TX ID if user forwarded/pasted a transaction ID message
962
+ # Format: "πŸ†” *Transaction ID* (tap to copy):\nABC123"
963
+ # or the user may include surrounding text like "Reverse this [ID]"
964
+ message_text = _extract_clean_message(message_text)
965
+
966
+ # Language sandwich
967
+ lang_data = detect_and_translate_input(message_text)
968
+ english_text = lang_data.get("english_text", message_text)
969
+ detected_lang = lang_data.get("detected_lang", "English")
970
+ logger.info(f"Detected language: {detected_lang}. Process text: {english_text}")
971
+
972
+ # Short-term context: resolve pronouns using last 3 exchanges
973
+ context_history = _load_context(db, mobile)
974
+ english_text = _resolve_context(english_text, context_history)
975
+
976
+ # Store this user message for future context resolution
977
+ _store_context(db, mobile, "user", message_text)
978
+
979
+ # ── Perishable nudge (prepend if any stock expiring) ──────────────────────
980
+ nudge = check_expiry_nudge(db, mobile)
981
+
982
+ if GREETING_PATTERN.match(english_text):
983
+ base_msg = "Hi there! I'm Qx-SmartLedger, your business assistant. How can I help?"
984
+ if nudge:
985
+ base_msg = nudge + "\n\n" + base_msg
986
+ final_msg = translate_output(base_msg, detected_lang)
987
+ wa_client.send_text_message(mobile, final_msg)
988
+ return final_msg
989
+
990
+ # ── Currency resolution β€” order matters ──────────────────────────────────
991
+ # 1. Check if THIS message is a currency reply first (breaks the loop)
992
+ currency_reply = _parse_currency_reply(english_text)
993
+ if currency_reply:
994
+ set_user_currency(db, mobile, currency_reply)
995
+ reply = f"Got it β€” I'll use *{currency_reply}* for all your transactions. Now, how can I help?"
996
+ final = translate_output(reply, detected_lang)
997
+ wa_client.send_text_message(mobile, final)
998
+ return final
999
+
1000
+ # 2. Load stored currency
1001
+ user_currency = get_user_currency(db, mobile)
1002
+ if not user_currency and user_settings:
1003
+ user_currency = (user_settings.get("currency") or
1004
+ user_settings.get("settings", {}).get("currency"))
1005
+
1006
+ # 3. If still no currency and this isn't a greeting, prompt once
1007
+ if not user_currency and not GREETING_PATTERN.match(english_text):
1008
+ wa_client.send_text_message(mobile, CURRENCY_PROMPT)
1009
+ return None
1010
+
1011
+ # 4. Check if this message is a price reply (e.g. "banana: 3.50\norange: 4")
1012
+ price_reply = _parse_price_reply(english_text)
1013
+ if price_reply is not None:
1014
+ if price_reply == {}:
1015
+ msg = translate_output("No problem β€” you can set prices anytime by telling me.", detected_lang)
1016
+ wa_client.send_text_message(mobile, msg)
1017
+ return msg
1018
+ msg = _apply_price_replies(db, mobile, price_reply)
1019
+ final = translate_output(msg, detected_lang)
1020
+ wa_client.send_text_message(mobile, final)
1021
+ return final
1022
+
1023
+ # 5. Check if this message provides missing details for a pending transaction
1024
+ # e.g. "total 45" or "paid 50" after bot asked for amount
1025
+ pending_ref = db.collection("users").document(mobile) .collection("temp_transactions").document("pending")
1026
+ pending_doc = pending_ref.get()
1027
+ if pending_doc.exists:
1028
+ missing_fill = _try_fill_missing_details(
1029
+ english_text, pending_doc.to_dict().get("transactions", [])
1030
+ )
1031
+ if missing_fill is not None:
1032
+ filled_txns, still_missing = missing_fill
1033
+ pending_ref.set({"transactions": filled_txns,
1034
+ "created_at": firestore.SERVER_TIMESTAMP})
1035
+ if still_missing:
1036
+ msg = translate_output(still_missing, detected_lang)
1037
+ wa_client.send_text_message(mobile, msg)
1038
+ return msg
1039
+ # All details now present β€” show confirmation
1040
+ summary = format_transaction_response(filled_txns)
1041
+ has_variance = any("amount_paid" in t.get("details", {}) for t in filled_txns)
1042
+ is_sale = filled_txns[0].get("transaction_type") == "sale"
1043
+ send_confirmation_buttons(mobile, translate_output(summary, detected_lang),
1044
+ is_variance=has_variance and is_sale)
1045
+ return None
1046
+
1047
+ llm_response_str = generateResponse(english_text, currency=user_currency)
1048
+ parsed_trans_data = parse_multiple_transactions(llm_response_str)
1049
+
1050
+ response_msg = ""
1051
+ send_image = False
1052
+ image_path = None
1053
+
1054
+ if not parsed_trans_data:
1055
+ response_msg = "Sorry, I couldn't quite understand that. Could you rephrase?"
1056
+ else:
1057
+ primary_intent = parsed_trans_data[0].get("intent", "").lower()
1058
+ primary_type = parsed_trans_data[0].get("transaction_type", "").lower()
1059
+
1060
+ if primary_intent == "read" or primary_type == "query":
1061
+ # Pass currency into transaction details for codegen context
1062
+ for t in parsed_trans_data:
1063
+ t.setdefault("details", {})["currency"] = user_currency
1064
+ response_data = process_intent(parsed_trans_data, mobile)
1065
+
1066
+ # Route based on result type
1067
+ if isinstance(response_data, tuple) and len(response_data) == 3 and response_data[0] == "TX_IDS":
1068
+ # Shouldn't happen on read, but handle gracefully
1069
+ _, summary, tx_ids = response_data
1070
+ response_msg = summary
1071
+
1072
+ elif isinstance(response_data, tuple) and len(response_data) == 2:
1073
+ # Plot: ("PLOT:filepath", insight_string)
1074
+ plot_path, insight = response_data
1075
+ if isinstance(plot_path, str) and plot_path.startswith("PLOT:"):
1076
+ plot_path = plot_path[5:]
1077
+ if isinstance(plot_path, str) and os.path.isfile(plot_path):
1078
+ send_image = True
1079
+ image_path = plot_path
1080
+ # Insight sent as follow-up after the image
1081
+ if insight:
1082
+ response_msg = f"πŸ’‘ {insight}"
1083
+ else:
1084
+ # File missing β€” fall back to insight text
1085
+ response_msg = insight or "Chart could not be generated."
1086
+
1087
+ else:
1088
+ response_msg = str(response_data) if response_data else "No data found."
1089
+
1090
+ elif primary_intent in ("create", "update", "delete", "reset_account"):
1091
+ if primary_intent == "reset_account":
1092
+ # Never execute reset directly β€” always require explicit confirmation
1093
+ if persist_temporary_transaction(parsed_trans_data, mobile):
1094
+ warning = (
1095
+ "⚠️ *This will permanently delete ALL your transactions, "
1096
+ "stock records, and price history.*\n\n"
1097
+ "This cannot be undone. Are you sure?"
1098
+ )
1099
+ warning_translated = translate_output(warning, detected_lang)
1100
+ wa_client.send_reply_buttons(
1101
+ recipient_id=mobile,
1102
+ body_text=warning_translated,
1103
+ button_data=[
1104
+ {"reply": {"id": "confirm_reset", "title": "πŸ—‘οΈ Yes, Delete All"}},
1105
+ {"reply": {"id": "cancel_transaction", "title": "❌ Cancel"}},
1106
+ ],
1107
+ )
1108
+ if nudge:
1109
+ wa_client.send_text_message(mobile, nudge)
1110
+ return None
1111
+ else:
1112
+ response_msg = "Could not process your request."
1113
+ else:
1114
+ # Check for missing required details before confirming
1115
+ missing_prompt = _check_missing_details(parsed_trans_data, user_currency)
1116
+ if missing_prompt:
1117
+ # Store partially-parsed transaction and ask for missing info
1118
+ persist_temporary_transaction(parsed_trans_data, mobile)
1119
+ final_prompt = translate_output(missing_prompt, detected_lang)
1120
+ wa_client.send_text_message(mobile, final_prompt)
1121
+ return None
1122
+
1123
+ if persist_temporary_transaction(parsed_trans_data, mobile):
1124
+ transaction_summary = format_transaction_response(parsed_trans_data)
1125
+ has_payment_input = any(
1126
+ "amount_paid" in t.get("details", {}) for t in parsed_trans_data
1127
+ )
1128
+ is_variance = (
1129
+ has_payment_input and
1130
+ primary_intent == "create" and
1131
+ primary_type == "sale"
1132
+ )
1133
+ trans_summary_translated = translate_output(transaction_summary, detected_lang)
1134
+ send_confirmation_buttons(mobile, trans_summary_translated,
1135
+ is_variance=is_variance)
1136
+ if nudge:
1137
+ wa_client.send_text_message(mobile, nudge)
1138
+ return None
1139
+ else:
1140
+ response_msg = "Sorry, I couldn't save your transaction for confirmation."
1141
+ else:
1142
+ response_msg = f"I'm not sure how to handle '{primary_intent}'."
1143
+
1144
+ # Prepend nudge to response
1145
+ if nudge and response_msg:
1146
+ response_msg = nudge + "\n\n" + response_msg
1147
+
1148
+ if response_msg:
1149
+ final_response = translate_output(response_msg, detected_lang)
1150
+
1151
+ if send_image and image_path:
1152
+ try:
1153
+ if not HEADERS_IMGUR:
1154
+ # No Imgur configured β€” skip image, send insight text only
1155
+ logger.warning("Imgur not configured β€” sending insight text only")
1156
+ wa_client.send_text_message(mobile, final_response or "Chart generated but Imgur not configured.")
1157
+ if os.path.exists(image_path): os.remove(image_path)
1158
+ return final_response
1159
+ with open(image_path, "rb") as f:
1160
+ resp = requests.post(URL_IMGUR, headers=HEADERS_IMGUR, files={"image": f})
1161
+ resp.raise_for_status()
1162
+ imgur_data = resp.json()
1163
+ if imgur_data.get("success"):
1164
+ wa_client.send_image_message(recipient_id=mobile,
1165
+ image_url=imgur_data["data"]["link"])
1166
+ os.remove(image_path)
1167
+ # Send insight as follow-up text if present
1168
+ if final_response and final_response.startswith("πŸ’‘"):
1169
+ wa_client.send_text_message(mobile, final_response)
1170
+ return final_response or None
1171
+ else:
1172
+ wa_client.send_text_message(mobile, final_response)
1173
+ os.remove(image_path)
1174
+ return final_response
1175
+ except Exception as e:
1176
+ logger.error(f"Image upload failed: {e}", exc_info=True)
1177
+ wa_client.send_text_message(mobile, final_response or "Chart could not be sent.")
1178
+ if os.path.exists(image_path): os.remove(image_path)
1179
+ return final_response
1180
+ else:
1181
+ wa_client.send_text_message(mobile, final_response)
1182
+ # Store bot response for future context
1183
+ _store_context(db, mobile, "bot", final_response)
1184
+ return final_response
1185
+
1186
+ return None
1187
+
1188
+ # ── Audio processor ────────────────────────────────────────────────────────────
1189
+
1190
+ def _deepgram_tts_to_mp3(text: str) -> Optional[str]:
1191
+ if not DEEPGRAM_API_KEY:
1192
+ return None
1193
+ capped = cap_for_tts(text) # Hard cap before sending to Deepgram
1194
+ try:
1195
+ resp = requests.post(
1196
+ DEEPGRAM_TTS_URL,
1197
+ headers={"Authorization": f"Token {DEEPGRAM_API_KEY}",
1198
+ "Content-Type": "application/json"},
1199
+ json={"text": capped},
1200
+ timeout=30,
1201
+ )
1202
+ resp.raise_for_status()
1203
+ filepath = os.path.join(os.getcwd(), f"tts_{uuid.uuid4()}.mp3")
1204
+ with open(filepath, "wb") as f:
1205
+ f.write(resp.content)
1206
+ return filepath
1207
+ except Exception as e:
1208
+ logger.error(f"DeepGram TTS failed: {e}", exc_info=True)
1209
+ return None
1210
+
1211
+
1212
+ def _upload_to_firebase_storage(file_path: str) -> Optional[str]:
1213
+ try:
1214
+ bucket = storage.bucket()
1215
+ blob = bucket.blob(f"audio_responses/{os.path.basename(file_path)}")
1216
+ blob.upload_from_filename(file_path)
1217
+ url = blob.generate_signed_url(expiration=timedelta(hours=1))
1218
+ return url
1219
+ except Exception as e:
1220
+ logger.error(f"Firebase Storage upload failed: {e}", exc_info=True)
1221
+ return None
1222
+
1223
+
1224
+ def process_audio_message(audio_id: str, mobile: str,
1225
+ user_settings: Optional[Dict]) -> None:
1226
+ if not transcriber:
1227
+ wa_client.send_text_message(mobile, "Audio processing is unavailable right now.")
1228
+ return
1229
+
1230
+ media_url = wa_client.get_media_url(audio_id)
1231
+ if not media_url:
1232
+ wa_client.send_text_message(mobile, "Couldn't retrieve your audio.")
1233
+ return
1234
+
1235
+ os.makedirs("temp_audio", exist_ok=True)
1236
+ audio_path = os.path.join("temp_audio", f"{mobile}_{audio_id}.ogg")
1237
+ downloaded = wa_client.download_media(media_url, audio_path)
1238
+ if not downloaded:
1239
+ wa_client.send_text_message(mobile, "Couldn't download your audio.")
1240
+ return
1241
+
1242
+ try:
1243
+ transcript = transcriber.transcribe(downloaded)
1244
+ if transcript.status == aai.TranscriptStatus.error:
1245
+ wa_client.send_text_message(mobile, f"Transcription error: {transcript.error}")
1246
+ elif not transcript.text:
1247
+ wa_client.send_text_message(mobile, "Couldn't understand the audio.")
1248
+ else:
1249
+ text_response = process_text_message(transcript.text, mobile, user_settings)
1250
+ if text_response:
1251
+ mp3_path = _deepgram_tts_to_mp3(text_response)
1252
+ if mp3_path:
1253
+ audio_url = _upload_to_firebase_storage(mp3_path)
1254
+ if audio_url:
1255
+ wa_client.send_audio_message(mobile, audio_url=audio_url)
1256
+ else:
1257
+ wa_client.send_audio_message(mobile, audio_path=mp3_path)
1258
+ if os.path.exists(mp3_path): os.remove(mp3_path)
1259
+ finally:
1260
+ if os.path.exists(downloaded): os.remove(downloaded)
1261
+
1262
+ # ── Image processor ────────────────────────────────────────────────────────────
1263
+
1264
+ def process_image_message(image_id: str, caption: Optional[str],
1265
+ mobile: str, user_settings: Optional[Dict]) -> None:
1266
+ logger.info(f"Processing image (ID: {image_id}) from {mobile}, caption: '{caption}'")
1267
+ wa_client.send_text_message(mobile, "Got your image β€” analysing... πŸ”")
1268
+
1269
+ media_url = wa_client.get_media_url(image_id)
1270
+ if not media_url:
1271
+ wa_client.send_text_message(mobile, "Couldn't retrieve your image from WhatsApp.")
1272
+ return
1273
+
1274
+ os.makedirs("temp_images", exist_ok=True)
1275
+ image_path = os.path.join("temp_images", f"{mobile}_{image_id}.jpg")
1276
+ downloaded = wa_client.download_media(media_url, image_path)
1277
+ if not downloaded:
1278
+ wa_client.send_text_message(mobile, "Couldn't download your image.")
1279
+ return
1280
+
1281
+ try:
1282
+ with open(downloaded, "rb") as f:
1283
+ image_bytes = f.read()
1284
+
1285
+ # If no caption β€” ask vendor what this image is for
1286
+ if not caption or not caption.strip():
1287
+ persist_pending_image(mobile, image_bytes, caption, db)
1288
+ send_image_intent_buttons(mobile)
1289
+ return
1290
+
1291
+ # Caption present β€” infer mode and process directly
1292
+ result = process_image_and_generate_query(image_bytes, caption, mode="auto")
1293
+ _handle_vision_result(mobile, result, caption)
1294
+
1295
+ except Exception as e:
1296
+ logger.error(f"Vision processing error: {e}", exc_info=True)
1297
+ wa_client.send_text_message(mobile, "Something went wrong analysing your image.")
1298
+ finally:
1299
+ if os.path.exists(downloaded):
1300
+ try: os.remove(downloaded)
1301
+ except Exception: pass
1302
+
1303
+ # ── Webhook ────────────────────────────────────────────────────────────────────
1304
+
1305
+ @app.route("/", methods=["GET", "POST"])
1306
+ def webhook_handler():
1307
+ if request.method == "GET":
1308
+ mode = request.args.get("hub.mode")
1309
+ token = request.args.get("hub.verify_token")
1310
+ challenge = request.args.get("hub.challenge")
1311
+ if mode == "subscribe" and token == VERIFY_TOKEN:
1312
+ return make_response(challenge, 200)
1313
+ return make_response("Verification failed", 403)
1314
+
1315
+ if request.method == "POST":
1316
+ try:
1317
+ data = request.get_json()
1318
+ msg_details = wa_client.get_message_details(data)
1319
+ if not msg_details:
1320
+ return make_response("ok", 200)
1321
+
1322
+ message_id = msg_details.get("id")
1323
+ mobile = msg_details.get("from")
1324
+ message_type = msg_details.get("type")
1325
+
1326
+ if check_and_mark_processed(message_id):
1327
+ return make_response("ok - duplicate", 200)
1328
+
1329
+ is_approved, user_data = is_user_approved(mobile)
1330
+ if not is_approved:
1331
+ wa_client.send_text_message(mobile, "Access denied. Please contact your administrator.")
1332
+ return make_response("ok", 200)
1333
+
1334
+ if message_type == "text":
1335
+ process_text_message(msg_details.get("text"), mobile, user_data)
1336
+ elif message_type == "audio":
1337
+ process_audio_message(msg_details.get("audio_id"), mobile, user_data)
1338
+ elif message_type == "image":
1339
+ process_image_message(msg_details.get("image_id"),
1340
+ msg_details.get("caption"), mobile, user_data)
1341
+ elif message_type == "interactive":
1342
+ handle_interactive_response(mobile, msg_details.get("button_reply_id"))
1343
+
1344
+ except Exception as e:
1345
+ logger.error(f"Unhandled exception in webhook: {e}", exc_info=True)
1346
+
1347
+ return make_response("ok", 200)
1348
+
1349
+ # ── Entry point ────────────────────────────────────────────────────────────────
1350
+
1351
+ if __name__ == "__main__":
1352
+ port = int(os.environ.get("PORT", 7860))
1353
+ debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
1354
+ print(f"===== Application Startup at {datetime.now()} =====")
1355
+ if not debug_mode:
1356
+ from waitress import serve
1357
+ serve(app, host="0.0.0.0", port=port)
1358
+ else:
1359
+ app.run(debug=True, host="0.0.0.0", port=port)