Skip to content
104 changes: 64 additions & 40 deletions src/fetchTelegramMessages.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,72 @@
import { TelegramClient } from "telegram";
import { Api } from "telegram";
import { Api, TelegramClient } from "telegram";
import { trackApiKeyUsage } from './utils/redisUtils';

export type TelegramMessage = { id: string; content: string; channelId: string };
export type TelegramMessages = { id: string; content: string; channelId: string };

export async function fetchTelegramMessages(
client: TelegramClient,
channel: string
): Promise<TelegramMessage[]> {
if (!channel) {
throw new Error("TG_CHANNEL environment variable is not set.");
client: TelegramClient,
channel: string
): Promise<TelegramMessages[]> {
if (!channel) {
throw new Error("TG_CHANNEL environment variable is not set.");
}
const apiId = process.env.API_ID;

Comment on lines +13 to +14
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Telemetry must be best-effort: don’t gate on API_ID and don’t throw if getMe() fails.

Tracking should never break message fetching. Also, gating on API_ID is unrelated.

-  const apiId = process.env.API_ID;
@@
-  // Track API usage after successful fetch
-  if (apiId) {
-    let accountId: string;
-    try {
-      const me = await client.getMe();
-      if (me && me.id) {
-        accountId = String(me.id);
-      } else {
-        throw new Error('Unable to determine Telegram account ID');
-      }
-    } catch (e) {
-      throw new Error('Unable to determine Telegram account ID');
-    }
-    await trackApiKeyUsage({ accountId, platform: 'telegram' });
-  }
+  // Track API usage after successful fetch (best-effort)
+  try {
+    const me = await client.getMe();
+    const accountId = me?.id ? String(me.id) : '';
+    if (accountId) {
+      await trackApiKeyUsage({ accountId, platform: 'telegram' });
+    } else {
+      console.warn('trackApiKeyUsage skipped: unable to determine Telegram account ID');
+    }
+  } catch (e) {
+    console.warn('trackApiKeyUsage skipped: unable to determine Telegram account ID', e);
+  }

Also applies to: 55-69

🤖 Prompt for AI Agents
In src/fetchTelegramMessages.ts around lines 13-14 (and similarly 55-69), the
code currently gates behavior on process.env.API_ID and allows getMe() failures
to throw, which can break message fetching; change it so telemetry is
best-effort: do not early-return or throw if API_ID is missing, and do not let
getMe() errors propagate. Specifically, stop gating core message fetching on
API_ID (treat API_ID as optional), wrap any telemetry/getMe() calls in a
try/catch that logs the error (or swallows it) without throwing, and proceed
with fetching messages regardless of telemetry success; ensure any telemetry
initialization is conditional and non-blocking so missing API_ID or getMe()
failures do not affect the main flow.

// Fetch channel entity to get the actual channel ID
let entity: Api.Channel;
try {
const resolved = await client.getEntity(channel);
if (resolved instanceof Api.Channel) {
entity = resolved;
} else if (resolved instanceof Api.ChannelForbidden) {
throw new Error(`TG_CHANNEL "${channel}" is a private/forbidden channel; cannot fetch history.`);
} else {
throw new Error(`TG_CHANNEL "${channel}" is not a channel-type peer.`);
}
// Fetch channel entity to get the actual channel ID
let entity: Api.Channel;
} catch (e) {
throw new Error(`Failed to resolve TG_CHANNEL "${channel}": ${e instanceof Error ? e.message : e}`);
}

const channelId = String(entity.id);

// Fetch the latest 10 messages
const messages = await client.invoke(
new Api.messages.GetHistory({
peer: entity,
limit: 10,
})
);

const out: TelegramMessages[] = [];

if ("messages" in messages) {
for (const msg of messages.messages as any[]) {
const id = typeof msg?.id === 'number' || typeof msg?.id === 'string' ? String(msg.id) : null;
const content = typeof msg?.message === 'string' ? msg.message : '';
if (!id || !content) continue; // skip service/media-only messages
const formatted = { id, content, channelId };
out.push(formatted);
console.log(formatted);
}
} else {
console.log("No messages property found in response:", messages);
}

// Track API usage after successful fetch
if (apiId) {
let accountId: string;
try {
const resolved = await client.getEntity(channel);
if (resolved instanceof Api.Channel) {
entity = resolved;
} else if (resolved instanceof Api.ChannelForbidden) {
throw new Error(`TG_CHANNEL "${channel}" is a private/forbidden channel; cannot fetch history.`);
} else {
throw new Error(`TG_CHANNEL "${channel}" is not a channel-type peer.`);
}
const me = await client.getMe();
if (me && me.id) {
accountId = String(me.id);
} else {
throw new Error('Unable to determine Telegram account ID');
}
} catch (e) {
throw new Error(`Failed to resolve TG_CHANNEL \"${channel}\": ${e instanceof Error ? e.message : e}`);
throw new Error('Unable to determine Telegram account ID');
}
const channelId = String(entity.id);
const messages = await client.invoke(
new Api.messages.GetHistory({
peer: entity,
limit: 10,
})
);
const out: TelegramMessage[] = [];
if ("messages" in messages) {
for (const msg of messages.messages as any[]) {
const id = typeof msg?.id === 'number' || typeof msg?.id === 'string' ? String(msg.id) : null;
const content = typeof msg?.message === 'string' ? msg.message : '';
if (!id || !content) continue; // skip service/media-only
const formatted = { id, content, channelId };
out.push(formatted);
console.log(formatted);
}
} else {
console.log("No messages property found in response:", messages);
}
return out;
await trackApiKeyUsage({ accountId, platform: 'telegram' });
}

return out;
}
27 changes: 14 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { TelegramClient } from "telegram";
import { StringSession } from "telegram/sessions";
import 'dotenv/config';
import input from "input"; // interactive input for login
import cron from 'node-cron';
import { runRedisOperation } from './utils/redisUtils';
import { TelegramClient } from "telegram";
import { StringSession } from "telegram/sessions";
import { fetchTelegramMessages } from './fetchTelegramMessages';
import 'dotenv/config';
import { getApiKeyUsage } from './utils/redisUtils';

// Replace these with your values
const apiId = Number(process.env.API_ID);
Expand Down Expand Up @@ -42,6 +42,15 @@ async function startTelegramCron() {
// Run once at startup
try {
await fetchTelegramMessages(client, process.env.TG_CHANNEL!);
// Print Telegram API usage by accountId (not API_ID)
const me = await client.getMe();
const accountId = String(me.id);
const usage = await getApiKeyUsage({accountId, platform:'telegram'});
console.log('Telegram API usage:', {
total_requests: usage.total_requests,
last_request: usage.last_request,
account_id: usage.account_id
});
} catch (err) {
console.error("Startup Telegram fetch failed:", err);
}
Expand All @@ -51,6 +60,7 @@ async function startTelegramCron() {
console.log('Refetching Telegram messages...');
try {
await fetchTelegramMessages(client, process.env.TG_CHANNEL!);
// No duplicate print of Telegram API usage
} catch (err) {
console.error('Scheduled Telegram fetch failed:', err);
}
Expand All @@ -64,14 +74,5 @@ startTelegramCron().catch((err) => {
});


async function main() {
await runRedisOperation(async (client) => {
await client.set('test-key', 'hello-redis');
const value = await client.get('test-key');
console.log('Read value from Redis:', value);
});
}

main();


61 changes: 50 additions & 11 deletions src/twitterApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import cron from 'node-cron';
import dotenv from 'dotenv';
import cron from 'node-cron';
import { getApiKeyUsage } from './utils/redisUtils';
import { trackApiKeyUsage } from './utils/redisUtils';
const AUTH_TOKEN = process.env.AUTH_TOKEN;
dotenv.config();
Comment on lines 1 to 6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Load .env before reading env vars; remove unused constant.

AUTH_TOKEN is read before dotenv.config() and never used.

-import dotenv from 'dotenv';
+import dotenv from 'dotenv';
+dotenv.config();
 import cron from 'node-cron';
 import { getApiKeyUsage } from './utils/redisUtils';
 import { trackApiKeyUsage } from './utils/redisUtils';
-const AUTH_TOKEN = process.env.AUTH_TOKEN;
-dotenv.config();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import dotenv from 'dotenv';
import cron from 'node-cron';
import { getApiKeyUsage } from './utils/redisUtils';
import { trackApiKeyUsage } from './utils/redisUtils';
const AUTH_TOKEN = process.env.AUTH_TOKEN;
dotenv.config();
import dotenv from 'dotenv';
dotenv.config();
import cron from 'node-cron';
import { getApiKeyUsage } from './utils/redisUtils';
import { trackApiKeyUsage } from './utils/redisUtils';
🤖 Prompt for AI Agents
In src/twitterApi.ts around lines 1 to 6, dotenv.config() is called after
reading AUTH_TOKEN and AUTH_TOKEN is never used; move dotenv.config() to the top
of the file before any process.env access, remove the unused AUTH_TOKEN
constant, and (optionally) consolidate the two redisUtils imports into a single
import statement to keep imports tidy.


export async function fetchHomeTimeline(seenTweetIds: string[] = []): Promise<Array<{ id: string; content: string; authorId: string }>> {
async function fetchViewerAccount(): Promise<{ screenName: string; userId: string } | null> {
const url = "https://x.com/i/api/graphql/jMaTSZ5dqXctUg5f97R6xw/Viewer";

const headers = {
"authorization": `Bearer ${process.env.BEARER}`,
"x-csrf-token": process.env.CSRF_TOKEN as string,
"cookie": `auth_token=${process.env.AUTH_TOKEN}; ct0=${process.env.CSRF_TOKEN}`,
};

const res = await fetch(url, { method: "GET", headers });
if (!res.ok) {
console.error("Viewer API request failed:", res.status, res.statusText);
return null;
}

const data = await res.json();
const user = data?.data?.viewer?.user_results?.result;
if (!user) return null;

return {
screenName: user.legacy?.screen_name,
userId: user.rest_id,
};
}

export async function fetchHomeTimeline(
seenTweetIds: string[] = []
): Promise<Array<{ id: string; content: string; authorId: string }>> {
const queryId = "wEpbv0WrfwV6y2Wlf0fxBQ";
const url = `https://x.com/i/api/graphql/${queryId}/HomeTimeline`;

Expand Down Expand Up @@ -93,13 +123,12 @@ export async function fetchHomeTimeline(seenTweetIds: string[] = []): Promise<Ar
throw new Error(`Twitter API errors: ${JSON.stringify(data.errors)}`);
}

// Format and return tweets as [{ content, id }]
// Format and return tweets
const timeline = data.data || data;
const tweets: Array<{ content: string, id: string, authorId: string }> = [];
const tweets: Array<{ content: string; id: string; authorId: string }> = [];
const seenTweetIdsSet = new Set(seenTweetIds);

try {
// Twitter's actual GraphQL HomeTimeline response structure
const instructions = timeline?.home?.home_timeline_urt?.instructions || [];

for (const instruction of instructions) {
Expand All @@ -124,6 +153,13 @@ export async function fetchHomeTimeline(seenTweetIds: string[] = []): Promise<Ar
console.error("Error parsing tweets:", e);
}

// Track API usage after successful fetch
if (process.env.TWITTER_ID) {
const accountId = process.env.TWITTER_ID;
console.log("Authenticated account:", accountId);
await trackApiKeyUsage({ accountId, platform: 'twitter' });
}

return tweets;
}

Expand All @@ -133,10 +169,15 @@ async function main() {
try {
const data = await fetchHomeTimeline();

// Show JSON response (truncated for readability)
const jsonString = JSON.stringify(data, null, 2);

console.log(jsonString);
const viewer = await fetchViewerAccount();
const accountId = viewer?.userId ?? process.env.TWITTER_ACCOUNT_ID;
if (!accountId) throw new Error('Missing TWITTER_ACCOUNT_ID and Viewer lookup failed.');
const usage = await getApiKeyUsage({accountId, platform:'twitter'});
console.log('Twitter API usage:', {
total_requests: usage.total_requests,
last_request: usage.last_request,
account_id: usage.account_id
});
} catch (err) {
console.error('fetchHomeTimeline failed:', err instanceof Error ? err.message : err);
process.exit(1);
Expand All @@ -151,10 +192,8 @@ cron.schedule('*/5 * * * *', async () => {
console.log('Refetching Twitter timeline...');
try {
const timeline = await fetchHomeTimeline();
// Process the timeline data here (save to DB, send to another service, etc.)
console.log('Fetched timeline:', timeline);
} catch (err) {
console.error('Scheduled Twitter timeline fetch failed:', err);
}
});

99 changes: 82 additions & 17 deletions src/utils/redisUtils.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,90 @@
import { createClient, RedisClientType } from 'redis';
import crypto from 'crypto';
import { createClient } from 'redis';

export async function runRedisOperation(operation: (client: RedisClientType) => Promise<void>): Promise<void> {
const client: RedisClientType = createClient({
url: 'redis://localhost:6379'
});
// Singleton Redis client
const redisClient = createClient({ url: 'redis://localhost:6379' });
let redisConnected = false;

client.on('error', (err) => {
console.error('Redis Client Error', err);
});
redisClient.on('error', (err) => {
console.error('Redis Client Error', err);
});

async function ensureRedisConnected() {
if (!redisConnected) {
await redisClient.connect();
redisConnected = true;
}
}
Comment on lines +5 to +17
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Make Redis URL configurable and fix connection check (use client.isOpen instead of a manual flag).

Hardcoding localhost breaks deployments and the boolean flag won’t recover after disconnects. Prefer REDIS_URL and client.isOpen.

Apply:

-import { createClient } from 'redis';
+import { createClient } from 'redis';

-// Singleton Redis client
-const redisClient = createClient({ url: 'redis://localhost:6379' });
-let redisConnected = false;
+// Singleton Redis client
+const redisClient = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' });

 async function ensureRedisConnected() {
-  if (!redisConnected) {
-    await redisClient.connect();
-    redisConnected = true;
-  }
+  if (!redisClient.isOpen) {
+    await redisClient.connect();
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const redisClient = createClient({ url: 'redis://localhost:6379' });
let redisConnected = false;
client.on('error', (err) => {
console.error('Redis Client Error', err);
});
redisClient.on('error', (err) => {
console.error('Redis Client Error', err);
});
async function ensureRedisConnected() {
if (!redisConnected) {
await redisClient.connect();
redisConnected = true;
}
}
const redisClient = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' });
redisClient.on('error', (err) => {
console.error('Redis Client Error', err);
});
async function ensureRedisConnected() {
if (!redisClient.isOpen) {
await redisClient.connect();
}
}
🤖 Prompt for AI Agents
In src/utils/redisUtils.ts around lines 5-17, the Redis URL is hardcoded and the
connection state uses a manual boolean that won't reflect real disconnects;
change createClient to read the URL from process.env.REDIS_URL (with an optional
fallback if you want), remove the redisConnected flag, and replace checks with
redisClient.isOpen (i.e., only call await redisClient.connect() when
!redisClient.isOpen). Keep the existing error handler and optionally add
listeners for 'end'/'reconnecting' for visibility, but do not rely on a manual
boolean to determine connection state.

export async function trackApiKeyUsage({ accountId, platform }: { accountId: string, platform: 'telegram' | 'twitter' }): Promise<void> {
if (!accountId?.trim()) {
console.warn('trackApiKeyUsage: empty accountId; skipping');
return;
}

try {
await client.connect();
await operation(client);
await ensureRedisConnected();
let key: string;
if (platform === 'twitter') {
key = `twitter_accounts:${accountId}`;
} else if (platform === 'telegram') {
key = `telegram_accounts:${accountId}`;
} else {
throw new Error('trackApiKeyUsage: platform must be "twitter" or "telegram"');
}
const now = new Date().toISOString();
await redisClient
.multi()
.hIncrBy(key, 'total_requests', 1)
.hSet(key, {
last_request: now,
account_id: accountId,
})
.exec();
} catch (err) {
console.error('Redis operation failed:', err);
throw err;
} finally {
try {
await client.quit();
} catch (quitErr) {
console.error('Error during Redis client quit:', quitErr);
console.warn('trackApiKeyUsage: non-fatal Redis error; proceeding without usage update', err);
}
}

/**
* Get API usage stats from Redis.
* @param accountId The account ID to query
* @param platform The platform ('telegram' or 'twitter')
* @returns Object with total_requests and last_request
*/

interface dataType {
accountId: string,
platform: 'telegram' | 'twitter'
}

export async function getApiKeyUsage(data: dataType): Promise<{ total_requests: number; last_request: string | null; account_id?: string }> {
const { accountId, platform } = data;
let result: { total_requests: number; last_request: string | null; account_id?: string } = { total_requests: 0, last_request: null };
if (!accountId?.trim()) {
return result;
}
if (platform !== 'twitter' && platform !== 'telegram') {
throw new Error('getApiKeyUsage: platform must be "twitter" or "telegram"');
}
try {
await ensureRedisConnected();
let key: string;
if (platform === 'twitter') {
key = `twitter_accounts:${accountId}`;
} else {
key = `telegram_accounts:${accountId}`;
}

const data = await redisClient.hGetAll(key);
result.total_requests = data.total_requests ? parseInt(data.total_requests) : 0;
result.last_request = data.last_request ? data.last_request : null;
if (data.account_id) {
result.account_id = data.account_id;
}
} catch (err) {
console.error('Redis operation failed:', err);
}
return result;
}