Back to Blog

From Telegram CSV to dbt Model — A 30-Minute Pipeline

April 28, 2026·
From Telegram CSV to dbt Model — A 30-Minute Pipeline

This is the technical companion piece to why Telegram belongs in your warehouse. Same architecture, fewer slides, more SQL.

We'll build a small but real pipeline:

  1. Extract group members and DM contacts to CSV.
  2. Load into Postgres (or BigQuery — same shape).
  3. Three dbt models: cleaned, enriched, and CRM-ready.
  4. The five queries you'll actually run against it.

Total time on a fresh laptop: ~30 minutes. Everything below is copy-pasteable.

1. Extract

Use a local extraction tool that outputs structured CSV. We use the Telegram Data Scraper Chrome extension because it processes data client-side, doesn't need bot tokens, and outputs the schema below directly.

Two CSVs per pull:

groups.csv

group_id,group_name,group_type,member_count,extracted_at
-100123,Founders SF,supergroup,432,2026-04-27T15:00:00Z
-100456,Crypto Allocators,supergroup,1180,2026-04-27T15:02:00Z

members.csv

group_id,user_id,username,display_name,is_bot,last_seen_at,extracted_at
-100123,9001,alice_btc,Alice T,false,2026-04-26T09:12:00Z,2026-04-27T15:00:00Z
-100123,9002,bob_eng,Bob R,false,2026-04-25T14:00:00Z,2026-04-27T15:00:00Z

If you also extract DM contacts, a third CSV:

contacts.csv

user_id,username,display_name,first_message_at,last_message_at,messages_sent,messages_received
9001,alice_btc,Alice T,2025-08-12T11:00:00Z,2026-04-26T18:00:00Z,42,28

That's it. Three flat files, no nesting, ready to load.

2. Load to Postgres

Spin up Postgres locally (or use any managed instance — BigQuery and Snowflake work the same with trivial syntax changes):

createdb network
psql network <<'SQL'
CREATE TABLE raw_telegram_groups (
  group_id BIGINT,
  group_name TEXT,
  group_type TEXT,
  member_count INT,
  extracted_at TIMESTAMPTZ
);

CREATE TABLE raw_telegram_members (
  group_id BIGINT,
  user_id BIGINT,
  username TEXT,
  display_name TEXT,
  is_bot BOOLEAN,
  last_seen_at TIMESTAMPTZ,
  extracted_at TIMESTAMPTZ
);

CREATE TABLE raw_telegram_contacts (
  user_id BIGINT,
  username TEXT,
  display_name TEXT,
  first_message_at TIMESTAMPTZ,
  last_message_at TIMESTAMPTZ,
  messages_sent INT,
  messages_received INT
);
SQL

psql network -c "\copy raw_telegram_groups FROM 'groups.csv' CSV HEADER"
psql network -c "\copy raw_telegram_members FROM 'members.csv' CSV HEADER"
psql network -c "\copy raw_telegram_contacts FROM 'contacts.csv' CSV HEADER"

If you already use dlt or Fivetran, point them at the CSVs instead. The schema is small enough that any loader works.

3. The dbt models

Three models. Stage → mart → activation.

Code on a laptop screen

stg_telegram_members.sql — clean, dedupe, normalize handles.

{{ config(materialized='table') }}

with deduped as (
  select
    user_id,
    lower(username)         as username,
    nullif(trim(display_name), '') as display_name,
    bool_or(is_bot)         as is_bot,
    max(last_seen_at)       as last_seen_at,
    array_agg(distinct group_id) as group_ids,
    count(distinct group_id)     as group_count
  from {{ source('raw', 'raw_telegram_members') }}
  where username is not null
  group by 1, 2, 3
)

select * from deduped

mart_warm_contacts.sql — apply the warm-score weighting.

{{ config(materialized='table') }}

with members as (
  select * from {{ ref('stg_telegram_members') }}
),

ranked_groups as (
  select
    group_id,
    group_name,
    case
      when group_name ilike '%founder%' then 1.0
      when group_name ilike '%crypto%' or group_name ilike '%defi%' then 0.9
      when group_name ilike '%dev%' or group_name ilike '%eng%' then 0.8
      else 0.4
    end as topical_weight
  from {{ source('raw', 'raw_telegram_groups') }}
),

scored as (
  select
    m.user_id,
    m.username,
    m.display_name,
    m.group_count,
    avg(rg.topical_weight)        as avg_topical_weight,
    max(m.last_seen_at)           as last_seen_at,
    case
      when max(m.last_seen_at) > now() - interval '7 days'  then 1.0
      when max(m.last_seen_at) > now() - interval '30 days' then 0.6
      when max(m.last_seen_at) > now() - interval '90 days' then 0.3
      else 0.1
    end as recency_weight
  from members m
  cross join lateral unnest(m.group_ids) as g(group_id)
  join ranked_groups rg on rg.group_id = g.group_id
  group by 1, 2, 3, 4
)

select
  *,
  round(
    (0.4 * least(group_count, 6) / 6.0)
    + (0.4 * avg_topical_weight)
    + (0.2 * recency_weight)
  , 3) as warm_score
from scored
order by warm_score desc

mart_crm_enrichment.sql — the table you reverse-ETL into Salesforce/HubSpot.

{{ config(materialized='table') }}

select
  user_id,
  username                                        as telegram_handle,
  display_name                                    as telegram_display_name,
  group_count                                     as mutual_groups_count,
  round(avg_topical_weight, 2)                    as topical_overlap_score,
  last_seen_at                                    as last_telegram_active_at,
  warm_score,
  case
    when warm_score >= 0.7 then 'A'
    when warm_score >= 0.4 then 'B'
    else 'C'
  end as warm_tier
from {{ ref('mart_warm_contacts') }}

4. The five queries you'll actually run

Once the models are in place, these are the queries that earn the pipeline its keep.

Tier-A prospects you haven't touched in 90 days.

select telegram_handle, mutual_groups_count, last_telegram_active_at
from mart_crm_enrichment e
left join crm_contacts c on c.telegram_handle = e.telegram_handle
where e.warm_tier = 'A'
  and (c.last_touched_at is null or c.last_touched_at < now() - interval '90 days')
order by warm_score desc
limit 50;

Group-density by ICP topic — where to sponsor or show up.

select
  rg.group_name,
  count(distinct e.user_id) as fit_buyers_in_group,
  count(distinct e.user_id) filter (where e.warm_tier in ('A','B')) as warm_in_group
from mart_crm_enrichment e
join {{ ref('stg_telegram_members') }} m on m.user_id = e.user_id
cross join lateral unnest(m.group_ids) as g(group_id)
join raw_telegram_groups rg on rg.group_id = g.group_id
group by 1
order by fit_buyers_in_group desc
limit 20;

Recently active warm contacts (the daily call list).

select telegram_handle, warm_score, last_telegram_active_at
from mart_crm_enrichment
where last_telegram_active_at > now() - interval '7 days'
  and warm_tier = 'A'
order by warm_score desc;

Account-to-Telegram match coverage.

select
  count(*) filter (where telegram_handle is not null)::float / count(*) as match_rate
from crm_accounts;

Reply-rate by warm tier (close the loop).

select
  e.warm_tier,
  count(distinct s.id) filter (where s.replied) ::float / count(distinct s.id) as reply_rate
from outbound_sends s
join mart_crm_enrichment e on e.telegram_handle = s.telegram_handle
where s.sent_at > now() - interval '30 days'
group by 1;

That last one is the most important. The whole point of the pipeline is to keep the model honest by measuring whether the score actually predicts replies. If reply rate doesn't separate tiers cleanly, retune α/β/γ and rerun.

5. Reverse-ETL it

Whichever reverse-ETL tool you use — Hightouch, Census, custom — point it at mart_crm_enrichment and sync into Salesforce/HubSpot:

  • telegram_handle (text)
  • mutual_groups_count (number)
  • topical_overlap_score (number, 0–1)
  • last_telegram_active_at (datetime)
  • warm_score (number, 0–1)
  • warm_tier (picklist A/B/C)

Now every account in your CRM has a Telegram presence the same way it has Bombora intent or Clearbit firmographics, and your reps can filter, route, and prioritize against it.

The pipeline took 30 minutes. The data was extracting itself in a Chrome tab while you read this.