Phone line intelligence
Before a number is dialled it is worth knowing what kind of line it is. A mobile
answers; a premium-rate line costs money; a voicemail-only number wastes an
attempt. Phone.LineIntelPending classifies every new number and blocks the ones
not worth calling.
Implementation: lambdas/phone_agent/events/line_intel_pending.py,
services/twilio_lookup.py.
Trigger and batching
The PhoneLineIntelTwiceDaily schedule fires Phone.LineIntelPending. Each run
selects rows from target_phone_numbers where syncTwilio is false
(list_pending_twilio_sync()), capped at PHONE_AGENT_BATCH_SIZE.
|
|
The handler returns immediately with an ERROR when CRM_SYNC_QUEUE_URL is not
configured — without it the CRM half of the job cannot happen.
Twilio Lookup v2
fetch_line_intelligence() issues a single GET against
https://lookups.twilio.com/v2/PhoneNumbers/{e164}?Fields=line_type_intelligence,line_status
using Basic auth built from TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN, with a
30-second timeout. HTTP and network errors are logged and re-raised, and the
per-number try/except in the handler moves on to the next row.
Parsed into TwilioLineIntelResult:
| Twilio field | Stored as |
|---|---|
|
|
|
|
|
|
|
|
|
|
derived |
|
Line type to risk tier
LineTypes mirrors Twilio’s vocabulary exactly, which is why the enum values are
camelCase (fixedVoip, nonFixedVoip, tollFree, sharedCost, uan) rather
than snake_case. An unrecognised type maps to None.
| Twilio type | Risk | Blocked | Reading |
|---|---|---|---|
|
low |
no |
Best case |
|
low |
no |
Fine |
|
medium |
no |
Business VoIP, usually legitimate |
|
medium |
no |
|
|
medium |
no |
|
|
medium |
no |
|
|
medium |
no |
Universal access number |
|
medium |
no |
Default for anything unmapped |
|
medium |
no |
|
|
high |
yes |
Disposable / burner VoIP |
|
critical |
yes |
Premium-rate; dialling costs money |
|
critical |
yes |
Voicemail only; no one to reach |
|
critical |
yes |
Not a voice line |
The blocking rule is one line: risk strictly above medium.
blocked = rl in (RiskLevel.HIGH, RiskLevel.CRITICAL)
isBlocked = True on a target_phone_numbers row is what keeps the number out of
future dialling selections.
Write-back
Per number, in order:
-
Update the
target_phone_numbersrow with all six Twilio fields plusisBlockedandsyncTwilio = True.syncTwiliois the idempotence marker — the row is never looked up again. A row that vanished between selection and update logs a warning and is skipped. -
Build HubSpot properties from whatever Twilio returned:
HubSpot contact property Value phone_carrier_namecarrier_namephone_line_typeThe raw Twilio type string
Both are custom properties the portal must define. Neither present means nothing to sync — logged as a warning, row skipped.
-
Resolve a HubSpot contact id. The
target_phone_numbersrow must have acontactId, and thatcontactsrow must have ahubspotContactId. Anything missing logs a warning naming the fix ("link contact with hubspotContactId or attach contactId") and the row is skipped — after the database write, so the Twilio call is not repeated on the next run. -
Publish
Contact.UpdatetoCrmSyncQueuewithMessageGroupId = target_phone_number_idandtarget_phone_number_idset on the event, somorpheus-hubspotstamps the row after a successful PATCH.
sequenceDiagram
participant EB as EventBridge
participant PA as phone-agent
participant TW as Twilio Lookup v2
participant DB as PostgreSQL
participant Q as CrmSyncQueue
participant HF as morpheus-hubspot
participant HS as HubSpot
EB->>PA: Phone.LineIntelPending
PA->>DB: list rows where syncTwilio = false (limit BATCH_SIZE)
loop each number
PA->>TW: GET line_type_intelligence
PA->>DB: update row + syncTwilio = true
PA->>DB: resolve contact → hubspotContactId
PA->>Q: Contact.Update (carrier, line type)
end
Q->>HF: dispatch
HF->>HS: PATCH contact properties
HF->>DB: stamp target_phone_numbers row
Where numbers come from
target_phone_numbers rows are created by:
-
Scraper.BigQueryContactSync— fromproperty_phoneandproperty_mobilephoneon the HubSpot contact export, normalised and deduplicated by number. -
Hubspot.Five9CallSync— creating a callee row for a number seen in a Five9 call log. -
The web application, on import or manual entry.
Each new row starts with syncTwilio = false, so it is picked up on the next
Phone.LineIntelPending run.
Number normalisation
morpheus_hubspot/utils/phone.py provides the shared normaliser, used by the Five9
sync and contact resolution:
| Input | Result |
|---|---|
Already E.164 ( |
Non-digits stripped, |
Bare 10-digit US number |
|
11+ digits without |
|
Leading |
Prefix stripped, |
Fewer than 7 digits, or empty |
|
phone_digits() strips the leading + for LIKE searches, so a query on
13023145526 matches both +13023145526 and 13023145526 as stored.
Cost control
ReservedConcurrentExecutions: 2 on the function, and a batch cap per run. Twilio
Lookup is billed per request, and syncTwilio guarantees each number is looked up
once. Re-running a number requires clearing the flag:
UPDATE target_phone_numbers SET "syncTwilio" = false
WHERE "phoneNumber" = '+13025550100';