
    2tj4                     ^   d Z ddlZddlZddlZddlZddlZddlZddlZddl	m
Z
mZ dZdZdZdZ ej                   ed	       d
dddddZdZdZdZdZd Zd Zd Zd Zd ZdZd Zd Zd Zd Zd Z d Z!d Z" G d  d!e
      Z#e$d"k(  r% ed#ez          ed$efe#      jK                          yy)%u  LFCS WhatsApp Cloud webhook — crew site-diary capture.

Meta pushes each inbound WhatsApp message here. This service:
  - answers Meta's GET verification handshake,
  - on POST, logs the message + fetches any photo/voice media (saved to disk),
  - sends a ONE-LINE nudge when a photo lands with no caption ("what task?").

Quiet by design: it only speaks the nudge, nothing else — the group is shared,
everyone's watching. Config (incl. the access token) lives in wa_config.json
(chmod 600), NEVER in this file. Runs as a systemd service behind nginx
(/wa-hook/<suffix> -> 127.0.0.1:8450).

Every POST is signature-checked against Meta's X-Hub-Signature-256 before it is
parsed or acted on (see do_POST). No valid signature, no handle() call, 403.

Stage: v1 = receive + save media + nudge. Diary write (transcribe/vision ->
Airtable Daily Record) is the next layer, built on real captured data.
    N)BaseHTTPRequestHandler
HTTPServerz/root/.hermes/wa_config.jsonz!/root/.hermes/logs/wa_webhook.logz/root/.hermes/wa_mediai!  T)exist_okjpgoggmp4bin)imageaudiovoicevideodocumentappE43UvTyARe5oJstbl8dT8HcPzFu3HlSfldrE1otZ6pBZaB7Iz/root/.hermes/config.yamlc                  F    t        j                  t        t                    S N)jsonloadopen	CONF_PATH     /root/.hermes/wa_webhook.pyconfr   &   s    99T)_%%r   c                  ^    dd l } | j                  t        t                    d   d   d   d   S )Nr   mcp_serversairtableenvAIRTABLE_API_KEY)yaml	safe_loadr   HERMES_CONFIG_YAML)r!   s    r   airtable_patr$   *   s/    >>$123MB:NuUVhiir   c                     t        d      D ];  }|j                  | dz         s|j                  dd      d   j                         c S  y)Nz/root/.hermes/.env=    )r   
startswithsplitstrip)klines     r   _envr.   /   sK    )* 1??1s7#::c1%a(..001 r   c                      	 t               j                  d      xs dj                         } | s	 t	        d      } | S | S # t        $ r d} Y w xY w# t        $ r d} Y | S w xY w)a  The Meta App Secret used to sign inbound webhooks. wa_config.json "app_secret" first
    (wa_subscribe.py already writes it there), /root/.hermes/.env WA_APP_SECRET second.
    Never hardcoded here, never printed, never logged, not even a few characters of it: a
    truncated secret in a logfile is still a lead, and this log is read by tooling. Returns
    "" when nothing is configured, and "" means REFUSE, never "skip the check".
app_secretr(   WA_APP_SECRET)r   getr+   	Exceptionr.   )ss    r   r0   r0   6   st    VZZ%+224 	_%A H1H  
  	AH	s    +? A AAAAc                    |r|j                  d      syt        j                  |j                         | t        j
                        j                         }t        j                  ||j                  dd      d   j                               S )aw  True if `header` (Meta's X-Hub-Signature-256) is HMAC-SHA256 of the RAW body bytes
    keyed with the app secret. Must be the bytes off the wire, before json.loads: any
    re-encode reorders or respaces the JSON and the digest stops matching. compare_digest,
    not ==, because == returns on the first wrong byte and leaks the right prefix to anyone
    timing the replies.zsha256=Fr&   r'   )
r)   hmacnewencodehashlibsha256	hexdigestcompare_digestr*   r+   )rawheadersecretmines       r   
verify_sigrA   H   sh     **9588FMMOS'..9CCEDtV\\#q%9!%<%B%B%DEEr   a  You are a construction tools and materials clerk for LFCS. Extract a JSON object from the site message. Schema: {"job": string|null (LFCS job number like "2631" if present), "direction": "out"|"in"|null (out=drawn/taken to job, in=returned to store), "items":[{"item": string, "qty": number|null, "unit": string|null}]}. Tradie knowledge: 4x2=90x45 timber, ply=plywood sheet, gen=generator, drop saw=mitre saw. Return ONLY JSON.c           	         t        d      }|syt        j                  ddddidt        dd	| dgd
      j	                         }t
        j                  j                  d|d|z   dd      }	 t        j                  t
        j                  j                  |d      j                         j                               }t        j                  |d   d   d   d         S # t        $ r}t        d|z         Y d}~yd}~ww xY w)zLow-sensitivity T&M parse via DeepSeek (OpenAI-compatible). Returns dict or None.
    T&M is not commercial-sensitive (no bids/rates/margins), so the cheap lane is fine.DEEPSEEK_API_KEYNzdeepseek-chatr   typejson_objectsystem)rolecontentuser)modeltemperatureresponse_formatmessagesz,https://api.deepseek.com/v1/chat/completionsBearer application/jsonAuthorizationzContent-Type)dataheaders   timeoutchoicesmessagerH   zPARSE ERR %s)r.   r   dumps	PARSE_SYSr8   urllibrequestRequestloadsurlopenreaddecoder3   log)msgkeybodyroutes         r   parse_tmri   ^   s    !
"C::+1=*A-5)$L-3$D$FG H IO 	 	MTX9BS8J(L 	 	MAjj//2/>CCELLNOzz#i.+I6yABB NQs   .A0C 	D (C;;D c                    	 t        j                  t        | idd      j                         }t        j
                  j                  dt        dt        |ddt               z   dd	      }t        j                  t        j
                  j                  |d
      j                         j                               }t        d|j                  d      z         y# t         $ r}t        d|z         Y d}~yd}~ww xY w)u   v1 T&M capture — raw text into a Movement Log row's Notes. The office (or a
    later smart-parse layer) structures it into tool/material/qty. Marked unverified.T)fieldstypecastzhttps://api.airtable.com/v0//POSTrN   rO   rP   rR   methodrS      rU   zMOVEMENT row %sidzMOVEMENT ERR %sN)r   rY   AT_NOTESr8   r[   r\   r]   AT_BASEAT_MOVEMENTr$   r^   r_   r`   ra   rb   r2   r3   )notere   rf   resrh   s        r   log_movementrx   s   s    	#zzh%54HIPPRNN""'S^#_(,V=F=W<N,P # Q jj//2/>CCELLNO-. #!""#s   CC 	C<$C77C<c                     t         j                   j                         j                  d      d| }t        t        d      5 }|j                  |dz          d d d        y # 1 sw Y   y xY w)Nseconds)timespec a
)datetimenow	isoformatr   LOGwrite)rc   r-   fs      r   rb   rb      sY    ''++-777KSQD	c3 1	t  s   A""A+c                     t         j                  j                  | dd|z   i      }t         j                  j                  |d      j	                         S )NrQ   rN   )rS   rT   rU   )r[   r\   r]   r_   r`   )urltokenrf   s      r   	graph_getr      sH    s_i%>O,PQA>>!!!R!05577r   c                    t        j                  t        d|d| |            }t        |d   |      }t        j                  j	                         j                  d      }t        j                  j                  t        |d| dt        j                  |d            }t        |d      5 }|j                  |       d d d        |t        |      fS # 1 sw Y   xY w)	Nhttps://graph.facebook.com/rm   r   z%Y%m%d-%H%M%S_.r	   wb)r   r^   r   r   r   strftimeospathjoin	MEDIA_DIREXTr2   r   r   len)	media_idmtyper   gvmetarR   stampr   r   s	            r   fetch_mediar      s    ::ib( SUZ[\DT%[%(D!!#,,_=E77<<	#''%QVBW#XYD	dD	 Q	T? s   .CCc                    |j                  d      st        d| z         y t        j                  d| dd|id      j	                         }d|d   d	|d
   d}t
        j                  j                  ||dd|d   z   dd      }	 t
        j                  j                  |d       t        d| z         y # t        $ r}t        d|z         Y d }~y d }~ww xY w)Naccess_tokenz"NUDGE skipped (no token yet) -> %swhatsapptextre   )messaging_producttorD   r   r   graph_versionrm   phone_number_idz	/messagesrn   rN   rO   rP   ro   rT   rU   zNUDGE sent -> %szNUDGE ERR %s)
r2   rb   r   rY   r8   r[   r\   r]   r_   r3   )r   re   cpayloadr   rf   rh   s          r   	send_textr      s    55 0256jjz"(64.B CCI68 9:?9KQO`Ma
bCs8AAnDU8U7I'K 	 	LA q"-#$  NQ s   /B7 7	C CCc                 >   | j                  dg       D ]k  }|j                  dg       D ]R  }|j                  di       }|j                  dg       D ]'  }|j                  d      }|j                  d      }|j                  d      }dx}	}
d }|d	k(  r#|j                  d	i       j                  d
d      }	n=|t        v r5|j                  |i       }|j                  d      }|j                  dd      }
d}|r0|j                  d      r	 t        |||d   |d         \  }}d||fz  }t	        d|d|d|d|	d|
d||       |	xs	 |
xs d|z  }|j                  d      r)d|j                  d      d   j                  d      d   z   nd}|	s|
rt        |      nd }|rydj                  d |j                  d g       D              xs d!}|j                  d"      xs d#}d$d%d&j                  |j                  d'      d(      }d)|d*|d*|d+|d,||}n
d-|d.||}t        |       |r |j                  d"      st        |d/|       |d0v s|
rt        ||j                  d1d2      |       * U n y # t        $ r}d|z  }Y d }~pd }~ww xY w)3NentrychangesvaluerM   fromrD   	timestampr(   r   re   rr   captionr   r   z saved=%s(%dB)z MEDIA_ERR=%sz	MSG from=z type=z ts=z text=z	 caption=z media=z[%s]z saved=z
photo on box: zsaved=(r   z; c              3      K   | ]k  }|j                  d       d|d    z  nd|j                  d      r|j                  d      dz   ndz   |j                  d      xs dz   j                          m yw)qtyNz%s r(   unitr|   item)r2   r+   ).0its     r   	<genexpr>zhandle.<locals>.<genexpr>   su      &;  02vve}/H%"U)+b68ffVnRVVF^c1"NFF6N0b23857;&;s   A1A3itemsz(no items read)jobz??zOUT (drawn to job)zIN (returned))rg   in	direction?u   AI-parsed (CHECK) — Job z | z
raw: z	 | from +u,   WhatsApp T&M capture (unverified) — from +r~   zGot it. Which job's it for?)r
   r   
nudge_textz!What job + tool/material is this?)r2   r   r   r3   rb   r)   r*   ri   r   rx   r   )r   r   r   chvmfrmr   tsr   r   r   nodesavedpnrh   r=   photoparsedr   r   dirnrv   s                           r   handler      s   Wb) 3`))Ir* 2	`Bw#AUU:r* 0`eeFmfUU;'!##wF?55,00<Dc\55+D#xx~H"hhy"5Gn 54*8UAn<MqQ`Oab1 0Aq6 9 E2tWhG H 9g9&5.!,,Y7 ,ekk(.CB.G.M.Mc.RST.UU=? +/7# II &; #)**Wb"9	&; ; P ?P	 
 !**U+3tC#7OSS

;/6D  #D%c5BD   #C0DT"&**U"3c#@!D00c1557Z#[]^_a0`2	`3`( % 4 /! 34s   	J	J	J	J	c                       e Zd Zd Zd Zd Zy)Handlerc                      y r   r   )selfr}   s     r   log_messagezHandler.log_message   s    r   c                 n   t         j                  j                  t         j                  j                  | j                        j
                        }|j                  ddg      d   }|j                  ddg      d   }|j                  ddg      d   }|dk(  rr|t               j                  d      k(  rV| j                  d       | j                          | j                  j                  |j                                t        d	       y | j                  d
       | j                          t        d       y )Nzhub.moder(   r   zhub.verify_tokenzhub.challenge	subscribeverify_token   z	VERIFY ok  zVERIFY fail (tok mismatch))r[   parseparse_qsurlparser   queryr2   r   send_responseend_headerswfiler   r8   rb   )r   qmodetokr   s        r   do_GETzHandler.do_GET   s    LL!!&,,"7"7		"B"H"HIuuZ"&q)ee&-a0UU?RD)!,;3$&**^*D#Ds#JJRYY[)s#,-r   c                    t        | j                  j                  dd            }| j                  j	                  |      }t               }|s-| j                  d       | j                          t        d       y t        || j                  j                  dd      |      s?| j                  d       | j                          t        d|| j                  d   fz         y | j                  d       | j                          	 t        t        j                  |      t                      y # t        $ r}t        d	|z         Y d }~y d }~ww xY w)
NzContent-Lengthr   r   zPOST REFUSED: no app_secret in wa_config.json and no WA_APP_SECRET in .env - webhook is deaf until one is set (set it, then restart wa-webhook)zX-Hub-Signature-256r(   zDPOST REJECTED: bad or missing X-Hub-Signature-256 (%d bytes from %s)r   zPOST ERR %s)intrS   r2   rfiler`   r0   r   r   rb   rA   client_addressr   r   r^   r   r3   )r   r   re   r?   rh   s        r   do_POSTzHandler.do_POST   s     !1156zzq!  s# W X$ 0 01F KVTs#Vd))!,-. /3	#4::d#TV, 	#!""	#s   'D* *	E3EEN)__name__
__module____qualname__r   r   r   r   r   r   r   r      s    .$#r   r   __main__zwa_webhook starting on :%dz	127.0.0.1)&__doc__r   r   r   r6   r9   urllib.requestr[   urllib.parsehttp.serverr   r   r   r   r   PORTmakedirsr   rt   ru   rs   r#   r   r$   r.   r0   rA   rZ   ri   rx   rb   r   r   r   r   r   r   serve_foreverr   r   r   <module>r      s   $ G F F F :*	)$	 I %SXY !0 &j
$	F 
*#8
 "4`n7#$ 7#t z$t+,T"G,::< r   