saitejatirunagari Claude Opus 4.8 commited on
Commit
1dc6052
Β·
1 Parent(s): 61643ea

feat: Telegram via Cloudflare Worker relay (HF blocks Telegram egress)

Browse files

Confirmed via /telegram/diag that HF blocks outbound to api.telegram.org.
Add a Cloudflare Worker relay (relay/cloudflare-worker.js + relay/README.md)
that reaches Telegram and calls HF /api/generate. Extend /api/generate:
accept jd_url (server-side JD fetch via src/jd_from_url), make jd_text optional,
and fall back to the bundled default resume when no resume is supplied.
Backward-compatible with the extension.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (5) hide show
  1. HISTORY.md +27 -0
  2. README.md +16 -20
  3. api_server.py +36 -3
  4. relay/README.md +51 -0
  5. relay/cloudflare-worker.js +142 -0
HISTORY.md CHANGED
@@ -4,6 +4,33 @@ A running log of everything built, fixed, and changed. Most recent first.
4
 
5
  ---
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## 2026-06-24 (AM3) β€” Fix Telegram bot: force IPv4 to api.telegram.org + retries
8
 
9
  After making the Space public, the webhook received updates (`POST /telegram/webhook
 
4
 
5
  ---
6
 
7
+ ## 2026-06-24 (AM4) β€” Telegram via Cloudflare relay (HF blocks Telegram egress)
8
+
9
+ `GET /telegram/diag` confirmed it definitively: from inside the Space,
10
+ `getMe` to api.telegram.org read-times-out at 5s with no proxy β€” **HF blocks
11
+ outbound to Telegram**. Inbound webhooks arrive, but the Space can never send
12
+ replies. IPv4/retries can't fix a blocked route.
13
+
14
+ Solution (owner chose "free relay host"): a **Cloudflare Worker** relay that can
15
+ reach Telegram. Telegram β†’ Worker β†’ HF `/api/generate` β†’ Worker sends the PDF.
16
+ `ctx.waitUntil` acks Telegram instantly then finishes the ~30–60s job in the
17
+ background (avoids Telegram's ~60s webhook-retry).
18
+
19
+ - `relay/cloudflare-worker.js` β€” the Worker (allowlist, status msgs, calls HF,
20
+ sends PDF or .tex fallback). Holds no resume data.
21
+ - `relay/README.md` β€” full Cloudflare setup + re-point the Telegram webhook.
22
+ - `api_server.py` `/api/generate` extended so the Worker stays thin:
23
+ - `jd_url` form field β†’ server fetches + extracts the JD via `src/jd_from_url.py`
24
+ (returns `{"error":"jd_unreadable"}` when a link can't be read so the bot asks
25
+ for pasted text);
26
+ - `jd_text` is now optional (required only if no `jd_url`);
27
+ - resume falls back to the bundled default when no `resume_latex`/PDF supplied.
28
+ Backward-compatible with the extension (still sends `jd_text` + `resume_latex`).
29
+ - The in-Space `/telegram/webhook` + `/telegram/diag` remain for diagnostics; the
30
+ working path is now the Cloudflare relay.
31
+
32
+ ---
33
+
34
  ## 2026-06-24 (AM3) β€” Fix Telegram bot: force IPv4 to api.telegram.org + retries
35
 
36
  After making the Space public, the webhook received updates (`POST /telegram/webhook
README.md CHANGED
@@ -258,29 +258,25 @@ Hybrid scoring: **70% JD Match + 30% Resume Quality**
258
 
259
  ---
260
 
261
- ## Telegram Bot (mobile touchpoint)
262
 
263
  Third touchpoint alongside the Streamlit app and Chrome extension: send a **job
264
  link** (or paste the JD) to your Telegram bot and get back a tailored resume PDF.
265
- Reuses the exact same pipeline (hardcoded default resume + Maximum ATS Mode +
266
- structured placement + Tectonic compile). Endpoint: `POST /telegram/webhook` on
267
- the HF Space; generation runs in a background task so Telegram gets an instant ACK.
268
-
269
- **Setup (one-time):**
270
- 1. In Telegram, message **@BotFather** β†’ `/newbot` β†’ copy the **bot token**.
271
- 2. Message **@userinfobot** β†’ copy your numeric **user ID**.
272
- 3. HF Space β†’ Settings β†’ Secrets, add:
273
- - `TELEGRAM_BOT_TOKEN` = the BotFather token
274
- - `TELEGRAM_ALLOWED_USER_IDS` = your numeric ID (comma-separated for multiple; **empty = nobody**, fail-closed)
275
- - `TELEGRAM_WEBHOOK_SECRET` = any random string (optional but recommended)
276
- 4. After the Space redeploys, register the webhook (paste in a browser, filling in
277
- token/secret/space):
278
- `https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://<your-space>.hf.space/telegram/webhook&secret_token=<SECRET>`
279
- (or run `python scripts/telegram_set_webhook.py set https://<your-space>.hf.space`).
280
-
281
- **Use:** send a job URL (Greenhouse/Lever/Ashby/Naukri/public pages fetch directly;
282
- LinkedIn/Indeed may be blocked server-side β†’ the bot asks you to paste the JD text),
283
- or just paste the full job description. The bot replies with the compiled PDF.
284
 
285
  ## Chrome Extension + JSON API
286
 
 
258
 
259
  ---
260
 
261
+ ## Telegram Bot (mobile touchpoint, via Cloudflare relay)
262
 
263
  Third touchpoint alongside the Streamlit app and Chrome extension: send a **job
264
  link** (or paste the JD) to your Telegram bot and get back a tailored resume PDF.
265
+
266
+ **Architecture note:** HF Spaces *block outbound traffic to `api.telegram.org`*, so
267
+ the HF app can receive webhooks but can't send replies. The bot therefore runs
268
+ through a tiny **Cloudflare Worker** relay (free, always-on, can reach Telegram):
269
+ Telegram β†’ Worker β†’ HF `/api/generate` (does JD-fetch + tailoring + Tectonic
270
+ compile) β†’ Worker sends the PDF back. Worker code + full setup:
271
+ [`relay/cloudflare-worker.js`](relay/cloudflare-worker.js) and
272
+ [`relay/README.md`](relay/README.md).
273
+
274
+ `/api/generate` accepts `jd_url` (server fetches/extracts the JD) or `jd_text`, and
275
+ falls back to the bundled default resume when no resume is supplied β€” so the relay
276
+ stays a thin Telegram-I/O layer.
277
+
278
+ (The legacy in-Space `POST /telegram/webhook` + `GET /telegram/diag` remain for
279
+ diagnostics, but the working path is the Cloudflare relay because of the egress block.)
 
 
 
 
280
 
281
  ## Chrome Extension + JSON API
282
 
api_server.py CHANGED
@@ -494,7 +494,8 @@ async def telegram_webhook(request: Request, background_tasks: BackgroundTasks):
494
  # ── /api/generate ─────────────────────────────────────────────────────────────
495
  @app.post("/api/generate")
496
  async def generate(
497
- jd_text: str = Form(...),
 
498
  job_title: str = Form(""),
499
  company: str = Form(""),
500
  maximum_ats_mode: str = Form(""), # "1"/"true" enables Maximum ATS Mode
@@ -508,10 +509,11 @@ async def generate(
508
  Generate a tailored resume DOCX (and optional PDF) for the supplied JD.
509
 
510
  Request: multipart/form-data
511
- - jd_text (required) β€” full job description text
 
512
  - job_title (optional) β€” for recruiter pitch header
513
  - company (optional) β€” for recruiter pitch header
514
- - resume (required) β€” PDF file bytes of the candidate's resume
515
  Header: X-Api-Token β€” must match API_SECRET_TOKEN env var (when set)
516
 
517
  Response: JSON with docx_b64, pdf_b64, status, scores, quality_flag, etc.
@@ -521,6 +523,37 @@ async def generate(
521
  max_ats = _truthy(maximum_ats_mode) or _truthy(user_confirmed_expansion)
522
  conf_terms = _term_list(confirmed_terms)
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  # ── LaTeX-first: if the user supplied LaTeX source, use it (priority over the
525
  # uploaded PDF) for keyword matching + scoring, then compile to PDF. ──────
526
  if (resume_latex or "").strip():
 
494
  # ── /api/generate ─────────────────────────────────────────────────────────────
495
  @app.post("/api/generate")
496
  async def generate(
497
+ jd_text: str = Form(""),
498
+ jd_url: str = Form(""), # Telegram relay: fetch JD from a link
499
  job_title: str = Form(""),
500
  company: str = Form(""),
501
  maximum_ats_mode: str = Form(""), # "1"/"true" enables Maximum ATS Mode
 
509
  Generate a tailored resume DOCX (and optional PDF) for the supplied JD.
510
 
511
  Request: multipart/form-data
512
+ - jd_text β€” full job description text (required unless jd_url given)
513
+ - jd_url β€” a job link; server fetches + extracts the JD (Telegram relay)
514
  - job_title (optional) β€” for recruiter pitch header
515
  - company (optional) β€” for recruiter pitch header
516
+ - resume β€” PDF bytes; OPTIONAL β€” falls back to the bundled default resume
517
  Header: X-Api-Token β€” must match API_SECRET_TOKEN env var (when set)
518
 
519
  Response: JSON with docx_b64, pdf_b64, status, scores, quality_flag, etc.
 
523
  max_ats = _truthy(maximum_ats_mode) or _truthy(user_confirmed_expansion)
524
  conf_terms = _term_list(confirmed_terms)
525
 
526
+ # ── jd_url β†’ fetch + extract the JD server-side (used by the Telegram relay) ──
527
+ if not (jd_text or "").strip() and (jd_url or "").strip():
528
+ try:
529
+ from src.jd_from_url import fetch_jd_from_url
530
+ res = fetch_jd_from_url(jd_url.strip())
531
+ if res.get("ok"):
532
+ jd_text = res["jd_text"]
533
+ job_title = job_title or res.get("job_title", "")
534
+ else:
535
+ return JSONResponse(
536
+ {"error": "jd_unreadable",
537
+ "detail": "Couldn't read that link server-side. Paste the job "
538
+ "description text instead."},
539
+ status_code=200,
540
+ )
541
+ except Exception as exc: # noqa: BLE001
542
+ return JSONResponse({"error": "jd_fetch_failed", "detail": str(exc)[:200]},
543
+ status_code=200)
544
+
545
+ if not (jd_text or "").strip():
546
+ return JSONResponse({"error": "jd_required",
547
+ "detail": "Provide jd_text or jd_url."}, status_code=200)
548
+
549
+ # ── Resume fallback: no LaTeX and no PDF β†’ use the bundled default resume. ────
550
+ if not (resume_latex or "").strip() and resume is None:
551
+ try:
552
+ from src.default_resume import get_default_resume_latex
553
+ resume_latex = get_default_resume_latex()
554
+ except Exception: # noqa: BLE001
555
+ pass
556
+
557
  # ── LaTeX-first: if the user supplied LaTeX source, use it (priority over the
558
  # uploaded PDF) for keyword matching + scoring, then compile to PDF. ──────
559
  if (resume_latex or "").strip():
relay/README.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Telegram β†’ Resume PDF relay (Cloudflare Worker)
2
+
3
+ HF Spaces block outbound traffic to `api.telegram.org`, so the HF app can receive
4
+ Telegram webhooks but can't send replies. This tiny Cloudflare Worker runs on a
5
+ network that *can* reach Telegram: it receives the webhook, calls your HF
6
+ `/api/generate` (which works publicly), and sends the PDF back to Telegram.
7
+
8
+ Cloudflare Workers are free, always-on, and `ctx.waitUntil()` lets the Worker ack
9
+ Telegram instantly then finish the ~30–60s job in the background (so Telegram
10
+ doesn't time out and retry).
11
+
12
+ ## One-time setup (~15 min)
13
+
14
+ 1. **Create the Worker**
15
+ - Go to https://dash.cloudflare.com β†’ **Workers & Pages** β†’ **Create** β†’ **Create Worker**.
16
+ - Give it a name (e.g. `jaa-telegram-relay`) β†’ **Deploy** (creates a placeholder).
17
+ - Click **Edit code**, delete the sample, paste the contents of
18
+ `relay/cloudflare-worker.js`, then **Deploy**.
19
+ - Copy the Worker URL, e.g. `https://jaa-telegram-relay.<you>.workers.dev`.
20
+
21
+ 2. **Set the Worker's Variables & Secrets**
22
+ Worker β†’ **Settings** β†’ **Variables and Secrets** β†’ add (type "Secret" for the token):
23
+ | Name | Value |
24
+ |---|---|
25
+ | `TELEGRAM_BOT_TOKEN` | your BotFather token |
26
+ | `ALLOWED_USER_IDS` | your numeric Telegram ID (comma-separated for more) |
27
+ | `HF_API_URL` | `https://saitejatirunagari-jaa-ats-tool.hf.space` |
28
+ | `HF_API_TOKEN` | your `API_SECRET_TOKEN` (same value as the HF secret) |
29
+ | `WEBHOOK_SECRET` | any random string (optional but recommended) |
30
+ Re-deploy after saving.
31
+
32
+ 3. **Point Telegram at the Worker** (paste in a browser, filling in token/secret/worker URL):
33
+ ```
34
+ https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://jaa-telegram-relay.<you>.workers.dev&secret_token=<WEBHOOK_SECRET>
35
+ ```
36
+ Expect `{"ok":true,...,"description":"Webhook was set"}`.
37
+ (This replaces the old HF webhook β€” the bot now flows through Cloudflare.)
38
+
39
+ 4. **Make sure `API_SECRET_TOKEN` is set on the HF Space** (it gates `/api/generate`)
40
+ and equals the Worker's `HF_API_TOKEN`.
41
+
42
+ ## Use
43
+ Message the bot a **job link** or paste the **job description** β†’ it replies with
44
+ the tailored PDF. The HF Space must be public (the Worker calls it unauthenticated
45
+ with the API token).
46
+
47
+ ## Notes
48
+ - First request after the HF Space has been idle is slower (cold start ~30s).
49
+ - LinkedIn/Indeed links may not be fetchable server-side; the bot will ask you to
50
+ paste the description text (always works).
51
+ - The Worker holds no resume data β€” HF uses your bundled default resume.
relay/cloudflare-worker.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Telegram β†’ resume PDF relay (Cloudflare Worker).
3
+ *
4
+ * WHY: Hugging Face Spaces block outbound traffic to api.telegram.org, so the HF
5
+ * app can RECEIVE Telegram webhooks but can't SEND replies. This tiny Worker runs
6
+ * on Cloudflare (which can reach Telegram), receives the webhook, calls the HF
7
+ * /api/generate endpoint (which works publicly), and sends the PDF back to Telegram.
8
+ *
9
+ * It acks Telegram instantly and finishes the ~30–60s job via ctx.waitUntil so
10
+ * Telegram never times out and retries.
11
+ *
12
+ * Deploy: paste into a Cloudflare Worker. Set these Variables/Secrets:
13
+ * TELEGRAM_BOT_TOKEN β€” from @BotFather
14
+ * ALLOWED_USER_IDS β€” comma-separated numeric Telegram user IDs (allowlist)
15
+ * HF_API_URL β€” https://<your-space>.hf.space
16
+ * HF_API_TOKEN β€” your API_SECRET_TOKEN (matches the HF secret)
17
+ * WEBHOOK_SECRET β€” optional; must match the setWebhook secret_token
18
+ * Then point the Telegram webhook at the Worker URL (see relay/README.md).
19
+ */
20
+
21
+ const HELP =
22
+ "πŸ‘‹ Send me a *job link* (or paste the full job description) and I'll send back " +
23
+ "your tailored, ATS-optimized resume PDF.\n\n" +
24
+ "β€’ Company/ATS links (Greenhouse, Lever, Ashby, Naukri) usually work directly.\n" +
25
+ "β€’ If I can't read a LinkedIn/Indeed link, copy the description text and send that.\n\n" +
26
+ "Commands: /start, /help";
27
+
28
+ export default {
29
+ async fetch(request, env, ctx) {
30
+ if (request.method !== "POST") return new Response("ok"); // health/GET
31
+ if (env.WEBHOOK_SECRET &&
32
+ request.headers.get("x-telegram-bot-api-secret-token") !== env.WEBHOOK_SECRET) {
33
+ return new Response("forbidden", { status: 403 });
34
+ }
35
+ let update;
36
+ try { update = await request.json(); } catch { return new Response("ok"); }
37
+ ctx.waitUntil(handle(update, env)); // finish in background; ack now
38
+ return new Response("ok");
39
+ },
40
+ };
41
+
42
+ function tgUrl(env, method) {
43
+ return `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`;
44
+ }
45
+
46
+ async function tg(env, method, payload) {
47
+ try {
48
+ await fetch(tgUrl(env, method), {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify(payload),
52
+ });
53
+ } catch (_) { /* best-effort */ }
54
+ }
55
+
56
+ async function handle(update, env) {
57
+ const msg = update.message || update.edited_message;
58
+ if (!msg) return;
59
+ const chatId = msg.chat && msg.chat.id;
60
+ const userId = msg.from && msg.from.id;
61
+ const text = (msg.text || "").trim();
62
+ if (chatId == null) return;
63
+
64
+ const allowed = (env.ALLOWED_USER_IDS || "")
65
+ .split(/[,\s]+/).filter(Boolean).map(Number);
66
+ if (!allowed.length || !allowed.includes(Number(userId))) {
67
+ await tg(env, "sendMessage", { chat_id: chatId, text: "β›” This bot is private." });
68
+ return;
69
+ }
70
+
71
+ if (!text || /^\/(start|help)\b/i.test(text)) {
72
+ await tg(env, "sendMessage", { chat_id: chatId, text: HELP, parse_mode: "Markdown" });
73
+ return;
74
+ }
75
+
76
+ const urlMatch = text.match(/https?:\/\/\S+/);
77
+ const form = new FormData();
78
+ if (urlMatch) {
79
+ await tg(env, "sendMessage", { chat_id: chatId, text: "πŸ” Reading the job & generating your resume… (~30–60s)" });
80
+ form.append("jd_url", urlMatch[0].replace(/[).,]+$/, ""));
81
+ } else if (text.length >= 200) {
82
+ await tg(env, "sendMessage", { chat_id: chatId, text: "βš™οΈ Generating your ATS resume… (~30–60s)" });
83
+ form.append("jd_text", text);
84
+ } else {
85
+ await tg(env, "sendMessage", { chat_id: chatId, text: "Send a job link, or paste the full job description (a paragraph or more)." });
86
+ return;
87
+ }
88
+ form.append("maximum_ats_mode", "1");
89
+
90
+ let res;
91
+ try {
92
+ const r = await fetch(`${env.HF_API_URL.replace(/\/$/, "")}/api/generate`, {
93
+ method: "POST",
94
+ headers: { "X-Api-Token": env.HF_API_TOKEN || "" },
95
+ body: form,
96
+ });
97
+ res = await r.json();
98
+ } catch (e) {
99
+ await tg(env, "sendMessage", { chat_id: chatId, text: "❌ Generation service is waking up or busy. Try again in a minute." });
100
+ return;
101
+ }
102
+
103
+ if (res.error === "jd_unreadable") {
104
+ await tg(env, "sendMessage", { chat_id: chatId, text: "⚠️ I couldn't read that link (LinkedIn/Indeed block server access). Please copy the *job description text* and send it to me." , parse_mode: "Markdown" });
105
+ return;
106
+ }
107
+ if (res.error) {
108
+ await tg(env, "sendMessage", { chat_id: chatId, text: `❌ ${res.detail || res.error}` });
109
+ return;
110
+ }
111
+
112
+ const cov = res.external_coverage_pct != null ? res.external_coverage_pct
113
+ : (res.scores && res.scores.jd_match);
114
+ if (res.pdf_b64) {
115
+ await sendDoc(env, chatId, res.pdf_b64, "resume.pdf", "application/pdf",
116
+ `βœ… Tailored resume β€” ~${cov ?? "?"}% JD keyword coverage.`);
117
+ } else if (res.tex_b64) {
118
+ await sendDoc(env, chatId, res.tex_b64, "resume.tex", "application/x-tex",
119
+ "⚠️ PDF compile failed β€” here's the .tex (compile at overleaf.com).");
120
+ } else {
121
+ await tg(env, "sendMessage", { chat_id: chatId, text: "❌ Generation produced no file. Try again." });
122
+ }
123
+ }
124
+
125
+ function b64ToBytes(b64) {
126
+ const bin = atob(b64);
127
+ const bytes = new Uint8Array(bin.length);
128
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
129
+ return bytes;
130
+ }
131
+
132
+ async function sendDoc(env, chatId, b64, filename, mime, caption) {
133
+ try {
134
+ const fd = new FormData();
135
+ fd.append("chat_id", String(chatId));
136
+ fd.append("caption", caption.slice(0, 1024));
137
+ fd.append("document", new Blob([b64ToBytes(b64)], { type: mime }), filename);
138
+ await fetch(tgUrl(env, "sendDocument"), { method: "POST", body: fd });
139
+ } catch (_) {
140
+ await tg(env, "sendMessage", { chat_id: chatId, text: "❌ Couldn't upload the file to Telegram. Try again." });
141
+ }
142
+ }