File size: 7,663 Bytes
1dc6052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f057ca2
1dc6052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42d2d15
 
 
 
 
 
 
 
 
 
 
1dc6052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42d2d15
 
 
 
 
 
 
 
 
 
 
1dc6052
42d2d15
 
 
1dc6052
 
 
f057ca2
 
58ed9c6
f057ca2
58ed9c6
f057ca2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1dc6052
 
 
 
 
 
 
 
58ed9c6
1dc6052
 
58ed9c6
1dc6052
 
 
 
 
 
f057ca2
1dc6052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348ccc1
1dc6052
 
348ccc1
1dc6052
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
 * Telegram β†’ resume PDF relay (Cloudflare Worker).
 *
 * WHY: Hugging Face Spaces block outbound traffic to api.telegram.org, so the HF
 * app can RECEIVE Telegram webhooks but can't SEND replies. This tiny Worker runs
 * on Cloudflare (which can reach Telegram), receives the webhook, calls the HF
 * /api/generate endpoint (which works publicly), and sends the PDF back to Telegram.
 *
 * It acks Telegram instantly and finishes the ~30–60s job via ctx.waitUntil so
 * Telegram never times out and retries.
 *
 * Deploy: paste into a Cloudflare Worker. Set these Variables/Secrets:
 *   TELEGRAM_BOT_TOKEN   β€” from @BotFather
 *   ALLOWED_USER_IDS     β€” comma-separated numeric Telegram user IDs (allowlist)
 *   HF_API_URL           β€” https://<your-space>.hf.space
 *   HF_API_TOKEN         β€” your API_SECRET_TOKEN (matches the HF secret)
 *   WEBHOOK_SECRET       β€” optional; must match the setWebhook secret_token
 * Then point the Telegram webhook at the Worker URL (see relay/README.md).
 */

const HELP =
  "πŸ‘‹ Send me a *job link* (or paste the full job description) and I'll send back " +
  "your tailored, ATS-optimized resume PDF.\n\n" +
  "β€’ Company/ATS links (Greenhouse, Lever, Ashby, Naukri) usually work directly.\n" +
  "β€’ If I can't read a LinkedIn/Indeed link, copy the description text and send that.\n\n" +
  "Commands: /start, /help, /v1, /v2, /mode";

export default {
  async fetch(request, env, ctx) {
    if (request.method !== "POST") return new Response("ok"); // health/GET
    if (env.WEBHOOK_SECRET &&
        request.headers.get("x-telegram-bot-api-secret-token") !== env.WEBHOOK_SECRET) {
      return new Response("forbidden", { status: 403 });
    }
    let update;
    try { update = await request.json(); } catch { return new Response("ok"); }
    ctx.waitUntil(handle(update, env));   // finish in background; ack now
    return new Response("ok");
  },
};

function parseAllowed(env) {
  // Robust: tolerate quotes, spaces, newlines around the comma-separated ids.
  return (env.ALLOWED_USER_IDS || "")
    .replace(/["']/g, "")
    .split(/[,\s]+/)
    .map((s) => s.trim())
    .filter(Boolean)
    .map(Number)
    .filter((n) => !Number.isNaN(n));
}

function tgUrl(env, method) {
  return `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`;
}

async function tg(env, method, payload) {
  try {
    await fetch(tgUrl(env, method), {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
  } catch (_) { /* best-effort */ }
}

async function handle(update, env) {
  const msg = update.message || update.edited_message;
  if (!msg) return;
  const chatId = msg.chat && msg.chat.id;
  const userId = msg.from && msg.from.id;
  const text = (msg.text || "").trim();
  if (chatId == null) return;

  // Diagnostic: /whoami always answers (before the allowlist) so you can compare
  // your real Telegram id against what the Worker actually sees in its env var.
  if (/^\/(whoami|id)\b/i.test(text)) {
    await tg(env, "sendMessage", { chat_id: chatId, text:
      `your id: ${userId}\n` +
      `ALLOWED_USER_IDS (raw): ${JSON.stringify(env.ALLOWED_USER_IDS ?? null)}\n` +
      `parsed: ${JSON.stringify(parseAllowed(env))}` });
    return;
  }

  const allowed = parseAllowed(env);
  if (!allowed.length || !allowed.includes(Number(userId))) {
    await tg(env, "sendMessage", { chat_id: chatId, text:
      `β›” Not authorized.\nyour id: ${userId}\n` +
      `ALLOWED_USER_IDS seen: ${JSON.stringify(env.ALLOWED_USER_IDS ?? null)}` });
    return;
  }

  // ── Per-user version mode (Workers KV optional) ──────────────────────────
  const verKey = `ver:${userId}`;
  let userVer = 'v2';
  if (env.USER_STATE) {
    try { userVer = (await env.USER_STATE.get(verKey)) || 'v2'; } catch (_) {}
  }

  if (/^\/v1\b/i.test(text)) {
    if (env.USER_STATE) { try { await env.USER_STATE.put(verKey, 'v1'); } catch (_) {} }
    await tg(env, "sendMessage", { chat_id: chatId, text: "βœ… Mode set to V1 (Structured keyword placement)." });
    return;
  }
  if (/^\/v2\b/i.test(text)) {
    if (env.USER_STATE) { try { await env.USER_STATE.put(verKey, 'v2'); } catch (_) {} }
    await tg(env, "sendMessage", { chat_id: chatId, text: "βœ… Mode set to V2 (Natural AI sentence integration)." });
    return;
  }
  if (/^\/mode\b/i.test(text)) {
    await tg(env, "sendMessage", { chat_id: chatId, text: `Current mode: ${userVer.toUpperCase()}` });
    return;
  }

  if (!text || /^\/(start|help)\b/i.test(text)) {
    await tg(env, "sendMessage", { chat_id: chatId, text: HELP, parse_mode: "Markdown" });
    return;
  }

  const urlMatch = text.match(/https?:\/\/\S+/);
  const form = new FormData();
  if (urlMatch) {
    await tg(env, "sendMessage", { chat_id: chatId, text: "πŸ” Reading the job & generating your resume… V2 runs several AI models, this can take 1–2 min." });
    form.append("jd_url", urlMatch[0].replace(/[).,]+$/, ""));
  } else if (text.length >= 200) {
    await tg(env, "sendMessage", { chat_id: chatId, text: "βš™οΈ Generating your ATS resume… V2 runs several AI models, this can take 1–2 min." });
    form.append("jd_text", text);
  } else {
    await tg(env, "sendMessage", { chat_id: chatId, text: "Send a job link, or paste the full job description (a paragraph or more)." });
    return;
  }
  form.append("maximum_ats_mode", "1");
  form.append("version", userVer);

  let res;
  try {
    const r = await fetch(`${env.HF_API_URL.replace(/\/$/, "")}/api/generate`, {
      method: "POST",
      headers: { "X-Api-Token": env.HF_API_TOKEN || "" },
      body: form,
    });
    res = await r.json();
  } catch (e) {
    await tg(env, "sendMessage", { chat_id: chatId, text: "❌ Generation service is waking up or busy. Try again in a minute." });
    return;
  }

  if (res.error === "jd_unreadable") {
    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" });
    return;
  }
  if (res.error) {
    await tg(env, "sendMessage", { chat_id: chatId, text: `❌ ${res.detail || res.error}` });
    return;
  }

  const cov = res.external_coverage_pct != null ? res.external_coverage_pct
            : (res.scores && res.scores.jd_match);
  if (res.pdf_b64) {
    await sendDoc(env, chatId, res.pdf_b64, "Saiteja_Tirunagari_Resume.pdf", "application/pdf",
                  `βœ… Tailored resume β€” ~${cov ?? "?"}% JD keyword coverage.`);
  } else if (res.tex_b64) {
    await sendDoc(env, chatId, res.tex_b64, "Saiteja_Tirunagari_Resume.tex", "application/x-tex",
                  "⚠️ PDF compile failed β€” here's the .tex (compile at overleaf.com).");
  } else {
    await tg(env, "sendMessage", { chat_id: chatId, text: "❌ Generation produced no file. Try again." });
  }
}

function b64ToBytes(b64) {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

async function sendDoc(env, chatId, b64, filename, mime, caption) {
  try {
    const fd = new FormData();
    fd.append("chat_id", String(chatId));
    fd.append("caption", caption.slice(0, 1024));
    fd.append("document", new Blob([b64ToBytes(b64)], { type: mime }), filename);
    await fetch(tgUrl(env, "sendDocument"), { method: "POST", body: fd });
  } catch (_) {
    await tg(env, "sendMessage", { chat_id: chatId, text: "❌ Couldn't upload the file to Telegram. Try again." });
  }
}