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.

PHONE_AGENT_BATCH_SIZE is 25 in lambdas/template.yaml, but the Settings default in models/config.py is 1. The template value applies in production; a local run without the variable set processes one number per invocation.

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

calling_country_code

countryCode

line_type_intelligence.carrier_name

carrierName

line_type_intelligence.mobile_country_code

mobileCountryCode

line_type_intelligence.mobile_network_code

mobileNetworkCode

line_type_intelligence.type

lineType (mapped to the LineTypes enum)

derived

riskLevel, isBlocked

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

mobile

low

no

Best case

landline

low

no

Fine

fixedVoip

medium

no

Business VoIP, usually legitimate

tollFree

medium

no

sharedCost

medium

no

voip

medium

no

uan

medium

no

Universal access number

unknown

medium

no

Default for anything unmapped

other

medium

no

nonFixedVoip

high

yes

Disposable / burner VoIP

premium

critical

yes

Premium-rate; dialling costs money

voicemail

critical

yes

Voicemail only; no one to reach

pager

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:

  1. Update the target_phone_numbers row with all six Twilio fields plus isBlocked and syncTwilio = True. syncTwilio is the idempotence marker — the row is never looked up again. A row that vanished between selection and update logs a warning and is skipped.

  2. Build HubSpot properties from whatever Twilio returned:

    HubSpot contact property Value

    phone_carrier_name

    carrier_name

    phone_line_type

    The raw Twilio type string

    Both are custom properties the portal must define. Neither present means nothing to sync — logged as a warning, row skipped.

  3. Resolve a HubSpot contact id. The target_phone_numbers row must have a contactId, and that contacts row must have a hubspotContactId. 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.

  4. Publish Contact.Update to CrmSyncQueue with MessageGroupId = target_phone_number_id and target_phone_number_id set on the event, so morpheus-hubspot stamps 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 — from property_phone and property_mobilephone on 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 (+1302…)

Non-digits stripped, + restored

Bare 10-digit US number

+1 prefixed

11+ digits without +

+ prefixed

Leading 011 or 001 international access prefix (without +)

Prefix stripped, + prefixed

Fewer than 7 digits, or empty

None

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';