Skip to content

Conversation

alisonhawk
Copy link
Collaborator

@alisonhawk alisonhawk commented Sep 18, 2025

Description:
This PR introduces Least Recently Used (LRU) account rotation for both Telegram and Twitter ingestion. The system now fetches the account that was used earliest from Redis and uses it for API calls, helping to distribute usage and avoid rate limits. Key changes include:

  • Added TelegramAccountManager service to manage Telegram account rotation and usage tracking via Redis.
  • Refactored ingestion logic to dynamically select and use the least recently used account for both platforms.
  • Updated .env migration script to store encrypted credentials for multiple accounts in Redis.
  • Improved type safety by replacing any[] with Record<string, string>[] where appropriate.
  • Added and updated test/demo scripts to verify account rotation.
  • Enhanced documentation to reflect the new rotation logic and setup steps.

This update ensures robust, scalable, and fair usage of multiple API accounts for both Telegram and Twitter.

Summary by CodeRabbit

  • New Features

    • Account rotation for Telegram and Twitter with automatic selection of the least-used account, per-account session handling, interactive Telegram login, and optional session export.
    • Per-account usage reporting and bulk usage retrieval; rotation runs on a 5-minute cadence with per-account logs.
  • Refactor

    • Switched to credential-backed, per-account API clients and rotation-aware scheduled fetches; startup/logging updated for account rotation.
  • Tests

    • Added demos and test scripts to exercise rotation, Redis integration, and usage tracking.

…gration

- Added TelegramAccountManager and TwitterAccountManager for managing account credentials and usage tracking.
- Refactored fetchTelegramMessages and fetchHomeTimeline to utilize account rotation logic.
- Introduced batch API key usage tracking for multiple accounts.
- Created demo scripts for simulating account rotation for both Telegram and Twitter.
- Updated moveEnvToRedis script to handle new environment variable mappings.
- Enhanced error handling and logging for better debugging and monitoring.
Copy link

coderabbitai bot commented Sep 18, 2025

Walkthrough

Implements per-account rotation for Telegram and Twitter: new Redis-backed base and platform account managers, per-account credential handling and client caching, updated APIs to select/mark least-recently-used accounts, adjusted function signatures, batch usage utilities, and rotation demo/test scripts.

Changes

Cohort / File(s) Summary
Core account manager & base
src/services/BaseAccountManager.ts
Adds Redis-backed BaseAccount and abstract BaseAccountManager<T> providing connection, locking (lock:${platform}:${accountId}), earliest-used selection, usage marking, and disconnect lifecycle.
Telegram account manager
src/services/telegramAccountManager.ts
New TelegramAccount and TelegramAccountManager singleton: loads/decrypts telegram-accounts from Redis, derives accountId, provides getEarliestUsedAccount, markAccountAsUsed, getAllAccountsWithCredentials/usage, and disconnect.
Twitter account manager
src/services/twitterAccountManager.ts
New TwitterAccount and TwitterAccountManager singleton: loads/decrypts twitter-accounts, derives accountId (SHA-256 based), provides rotation/usage APIs, local usage tracking, and locking.
Telegram fetch & normalization
src/fetchTelegramMessages.ts
Signature changed to accept TelegramAccount; reads channel from account.credentials.TELEGRAM_TG_CHANNEL, validates presence, resolves entity via client.getEntity, enforces channel-type/forbidden checks, fetches history with Api.messages.GetHistory, maps messages to {id, content, channelId}, logs and skips malformed entries. Removed env-based api id/getMe flow.
Index / client & rotation
src/index.ts
Adds per-account Telegram client cache and createTelegramClient(account) with Redis session handling and interactive login, switches startup to account-rotation cron (5 min) that selects earliest-used account, ensures client, fetches Telegram messages, and marks usage via manager.
Twitter API rotation
src/twitterApi.ts
Replaces env tokens with per-account creds via twitterAccountManager. fetchHomeTimeline accepts optional providedAccount (otherwise selects earliest-used) and uses account creds for headers/cookies; fetchViewerAccount now requires an account. Marks usage with markAccountAsUsed.
Redis utilities
src/utils/redisUtils.ts
Adds getBatchApiKeyUsage(accountIds, platform) to fetch usage hashes for multiple accounts (platform validated), returns array {accountId, total_requests, last_request} with partial-error tolerance.
Env→Redis helper
src/utils/moveEnvToRedis.ts
Loads dotenv/config on import (auto-populates process.env). Minor mapping tweaks for Telegram key names when persisting.
Demos & tests — Telegram
src/tests/telegramRotationDemo.ts, src/tests/testTelegramRotation.ts
New demo/test scripts exercising telegramAccountManager: Redis checks, multi-cycle selection/marking, printing initial/final usage, error guidance and teardown (disconnect()), with exported test helpers.
Demos & tests — Twitter
src/tests/rotationDemo.ts, src/tests/testRotation.ts
New demo/test scripts for Twitter rotation using twitterAccountManager: Redis verification, multi-cycle rotation, usage printing, exported test helpers, and teardown.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Cron as Cron (5m)
  participant App as index.ts
  participant Mgr as TelegramAccountManager
  participant Cache as ClientCache
  participant TGClient as TelegramClient
  participant API as Telegram API

  Cron->>App: Trigger rotation cycle
  App->>Mgr: getEarliestUsedAccount()
  Mgr-->>App: TelegramAccount
  App->>Cache: get/create client(account)
  Cache-->>App: TelegramClient
  App->>TGClient: ensure logged in / start if needed
  App->>API: Api.messages.GetHistory(peer: entity, limit: 10)
  API-->>App: messages
  App->>Mgr: markAccountAsUsed(accountId)
  App-->>Cron: log results
Loading
sequenceDiagram
  autonumber
  actor Cron as Cron (5m)
  participant Api as twitterApi.ts
  participant Mgr as TwitterAccountManager
  participant TW as Twitter API

  Cron->>Api: fetchHomeTimeline(seenIds)
  Api->>Mgr: getEarliestUsedAccount()
  Mgr-->>Api: TwitterAccount
  Api->>TW: GET HomeTimeline (headers from account.credentials)
  TW-->>Api: timeline tweets
  Api->>Mgr: markAccountAsUsed(accountId)
  Api-->>Cron: log tweets count
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • tasin2610

Poem

I hop through Redis rows by moonlit code,
Choosing the quiet key where timestamps goad.
Sessions tucked in burrows, clients warmed and cached,
I nudge the least-used carrot and spin the queue unabashed.
Tweets and tings rotate — a rabbit's routing dash. 🥕🐇

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly and accurately summarizes the primary change: implementing LRU-based account rotation for Telegram and Twitter with Redis integration. It is specific, concise, and directly reflects the PR objectives and modified modules.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/shuffle-acc-to-lru-for-usage

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/utils/redisUtils.ts (1)

5-17: Unify Redis config and use isOpen instead of a sticky flag.

Hard-coded localhost and a manual redisConnected boolean are brittle and inconsistent with moveEnvToRedis.ts (which uses process.env.REDIS_URL). Use env URL with a fallback and rely on redisClient.isOpen.

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

 async function ensureRedisConnected() {
-  if (!redisConnected) {
-    await redisClient.connect();
-    redisConnected = true;
-  }
+  if (!redisClient.isOpen) {
+    await redisClient.connect();
+  }
 }
src/fetchTelegramMessages.ts (1)

45-53: Don’t log raw message content by default.

Printing each message body risks PII leakage. Gate under a debug flag or remove.

-      const formatted = { id, content, channelId };
-      out.push(formatted);
-      console.log(formatted);
+      const formatted = { id, content, channelId };
+      out.push(formatted);
+      if (process.env.DEBUG_TELEGRAM === '1') {
+        console.log(formatted);
+      }
src/twitterApi.ts (1)

189-201: Avoid side effects on import; gate main() and cron behind CLI check

Running main() and scheduling cron at module load makes this file unusable as a library and risks duplicate jobs if imported elsewhere. Guard with a CLI entrypoint.

Apply this diff:

-// Run once at startup
-main();
-
-// Schedule to run every 5 minutes - automatically rotates to earliest used account
-cron.schedule('*/5 * * * *', async () => {
-  console.log('Refetching Twitter timeline with account rotation...');
-  try {
-    const timeline = await fetchHomeTimeline();
-    console.log(`Fetched ${timeline.length} tweets`);
-  } catch (err) {
-    console.error('Scheduled Twitter timeline fetch failed:', err);
-  }
-});
+// Run only when executed directly
+if (require.main === module) {
+  // Run once at startup
+  main();
+  // Schedule to run every 5 minutes - automatically rotates to earliest used account
+  cron.schedule('*/5 * * * *', async () => {
+    console.log('Refetching Twitter timeline with account rotation...');
+    try {
+      const timeline = await fetchHomeTimeline();
+      console.log(`Fetched ${timeline.length} tweets`);
+    } catch (err) {
+      console.error('Scheduled Twitter timeline fetch failed:', err);
+    }
+  });
+}
🧹 Nitpick comments (28)
src/utils/redisUtils.ts (1)

96-131: Make batch usage retrieval concurrent and robust (avoid per-key awaits; handle NaN).

The current loop performs sequential hGetAll. Pipeline with Promise.all (or multi) and guard numeric parsing.

 export async function getBatchApiKeyUsage(
   accountIds: string[],
   platform: 'telegram' | 'twitter'
 ): Promise<Array<{ accountId: string; total_requests: number; last_request: string | null }>> {
   if (platform !== 'twitter' && platform !== 'telegram') {
     throw new Error('getBatchApiKeyUsage: platform must be "twitter" or "telegram"');
   }

-  const results: Array<{ accountId: string; total_requests: number; last_request: string | null }> = [];
-
   try {
     await ensureRedisConnected();
 
-    for (const accountId of accountIds) {
-      if (!accountId?.trim()) continue;
-
-      let key: string;
-      if (platform === 'twitter') {
-        key = `twitter_accounts:${accountId}`;
-      } else {
-        key = `telegram_accounts:${accountId}`;
-      }
-
-      const data = await redisClient.hGetAll(key);
-      results.push({
-        accountId,
-        total_requests: data.total_requests ? parseInt(data.total_requests) : 0,
-        last_request: data.last_request ? data.last_request : null,
-      });
-    }
+    const prefix = platform === 'twitter' ? 'twitter_accounts:' : 'telegram_accounts:';
+    const ids = accountIds.filter(id => id?.trim());
+    const keys = ids.map(id => `${prefix}${id}`);
+    const replies = await Promise.all(keys.map(k => redisClient.hGetAll(k)));
+    return ids.map((accountId, i) => {
+      const data = replies[i] ?? {};
+      const n = Number(data.total_requests);
+      return {
+        accountId,
+        total_requests: Number.isFinite(n) ? n : 0,
+        last_request: data.last_request ?? null,
+      };
+    });
   } catch (err) {
     console.error('getBatchApiKeyUsage: Redis operation failed:', err);
   }
 
-  return results;
+  return [];
 }
src/utils/moveEnvToRedis.ts (4)

17-21: Exclude REDIS_URL from being written back to Redis.

Storing the store’s connection string inside the store is unnecessary and risky. Exclude it alongside ENCRYPTION_KEY.

-    const EXCLUDE = new Set(['ENCRYPTION_KEY']);
+    const EXCLUDE = new Set(['ENCRYPTION_KEY', 'REDIS_URL']);

28-32: Type the key map to prevent accidental key mismatches.

Mark the map as readonly to tighten typings.

-    const telegramKeyMap = {
+    const telegramKeyMap = {
         'API_ID': 'TELEGRAM_API_ID',
         'API_HASH': 'TELEGRAM_API_HASH',
         'TG_CHANNEL': 'TELEGRAM_TG_CHANNEL'
-    };
+    } as const;

51-61: Replace any[] with typed arrays (aligns with PR objective).

Use Record<string, string>[] for stronger type safety.

-    if (Object.keys(twitterAccount).length) {
-        let twitterArr: any[] = [];
+    if (Object.keys(twitterAccount).length) {
+        let twitterArr: Record<string, string>[] = [];
         const existing = await redisClient.get('twitter-accounts');
         if (existing) {
             try {
-                twitterArr = JSON.parse(existing);
+                twitterArr = JSON.parse(existing) as Record<string, string>[];
             } catch { }
         }
         twitterArr.push(twitterAccount);
         await redisClient.set('twitter-accounts', JSON.stringify(twitterArr));
     }

62-72: Apply the same typing to Telegram accounts.

-    if (Object.keys(telegramAccount).length) {
-        let telegramArr: any[] = [];
+    if (Object.keys(telegramAccount).length) {
+        let telegramArr: Record<string, string>[] = [];
         const existing = await redisClient.get('telegram-accounts');
         if (existing) {
             try {
-                telegramArr = JSON.parse(existing);
+                telegramArr = JSON.parse(existing) as Record<string, string>[];
             } catch { }
         }
         telegramArr.push(telegramAccount);
         await redisClient.set('telegram-accounts', JSON.stringify(telegramArr));
     }
src/tests/rotationDemo.ts (1)

84-89: Avoid brittle string matching for control flow; prefer typed errors.

Checking error.message.includes('No Twitter accounts found') is fragile. Have twitterAccountManager throw a dedicated NoAccountsError (or error code) and test via instanceof or a stable code.

src/tests/telegramRotationDemo.ts (1)

87-92: Prefer typed errors over string matching.

Replace error.message.includes('No Telegram accounts found') with a dedicated error type or code from the manager.

src/twitterApi.ts (5)

16-16: Add request timeouts to external calls

fetch calls have no timeout; a stuck connection can hang the job. Use AbortController with a configurable timeout.

-const res = await fetch(url, { method: "GET", headers });
+const controller = new AbortController();
+const to = setTimeout(() => controller.abort(), Number(process.env.HTTP_TIMEOUT_MS ?? 15000));
+let res: Response;
+try {
+  res = await fetch(url, { method: "GET", headers, signal: controller.signal });
+} finally {
+  clearTimeout(to);
+}
-const response = await fetch(url, {
-  method: "POST",
-  headers,
-  body: JSON.stringify(body),
-});
+const controller = new AbortController();
+const to = setTimeout(() => controller.abort(), Number(process.env.HTTP_TIMEOUT_MS ?? 15000));
+let response: Response;
+try {
+  response = await fetch(url, {
+    method: "POST",
+    headers,
+    body: JSON.stringify(body),
+    signal: controller.signal,
+  });
+} finally {
+  clearTimeout(to);
+}

Additionally, declare once near the top if preferred:

// optionally at top-level
const DEFAULT_HTTP_TIMEOUT_MS = Number(process.env.HTTP_TIMEOUT_MS ?? 15000);

Also applies to: 104-110


36-36: Externalize brittle GraphQL queryId

Hardcoding the queryId is fragile. Make it configurable and default to the current value.

-const queryId = "wEpbv0WrfwV6y2Wlf0fxBQ";
+const queryId = process.env.TW_HOME_TIMELINE_QID ?? "wEpbv0WrfwV6y2Wlf0fxBQ";

Please verify the current valid queryId in your environment before deploy.

Also applies to: 101-101


62-62: Remove redundant fallback

seenTweetIds already defaults to [], so the extra || [] is unnecessary.

-      seenTweetIds: seenTweetIds || [],
+      seenTweetIds,

171-174: Avoid logging PII in routine runs

Logging Account info (screenName/userId) may be sensitive. Gate behind a DEBUG flag or redact.

Example:

if (process.env.DEBUG === '1') {
  console.log('Account info:', viewer);
}

39-46: Guard for empty account pool and mark usage only after successful parse

getEarliestUsedAccount may throw when no accounts exist. Ensure clear error and avoid partial usage updates.

  • Confirm twitterAccountManager.getEarliestUsedAccount() throws a descriptive error when the pool is empty.
  • Current placement of markAccountAsUsed happens after response OK; good. Keep it that way to avoid penalizing failed calls.

Also applies to: 154-158

src/tests/testRotation.ts (2)

120-131: Prefer returning non-zero exit code without abrupt process.exit in shared test utilities

If this script is ever imported, process.exit(1) can terminate parent processes. Since this is mainly a CLI, it’s acceptable; consider returning a status and exiting only under the CLI guard.

Pattern:

if (require.main === module) {
  const ok = await main();
  process.exit(ok ? 0 : 1);
}

88-107: Reuse existing Redis connection to reduce overhead (optional)

You spin up a dedicated Redis client to read twitter-accounts, while twitterAccountManager maintains its own client. For tests, it’s fine; consider delegating this check to the manager or sharing a client to reduce connection churn.

src/tests/testTelegramRotation.ts (3)

129-139: CLI exit vs. library import

Same note as Twitter tests: exiting the process is fine for a CLI, but return a status from main() and only exit under the direct-execution guard.


30-36: Avoid printing decrypted channel names in shared logs

Channel identifiers can be sensitive. Consider gating with DEBUG and/or redacting.


90-108: Reduce duplicate Redis connections (optional)

Like the Twitter test, you create a new Redis client while telegramAccountManager manages one. It’s okay for a test, but you can reuse/ask the manager for a lightweight “exists” check.

src/services/telegramAccountManager.ts (4)

30-35: Make Redis connect idempotent under concurrency

Two concurrent calls can both attempt connect() before isConnected flips, causing noisy errors. Track a connecting promise.

-  private async ensureConnected(): Promise<void> {
-      if (!this.isConnected) {
-          await this.redisClient.connect();
-          this.isConnected = true;
-      }
-  }
+  private connecting?: Promise<void>;
+  private async ensureConnected(): Promise<void> {
+    if (this.isConnected) return;
+    if (!this.connecting) {
+      this.connecting = (async () => {
+        await this.redisClient.connect();
+        this.isConnected = true;
+      })().finally(() => {
+        this.connecting = undefined;
+      });
+    }
+    await this.connecting;
+  }

43-53: Graceful handling when no accounts are present

Throwing is fine for callers expecting it; consider returning an empty array from fetchAllAccounts() and throwing only in getEarliestUsedAccount() with a clear message.

- if (!raw) {
-   throw new Error('No Telegram accounts found in Redis');
- }
+ if (!raw) {
+   return [];
+ }
...
- if (accounts.length === 0) {
-   throw new Error('No valid Telegram accounts could be decrypted');
- }
+ if (accounts.length === 0) {
+   return [];
+ }

Then in getEarliestUsedAccount():

if (accounts.length === 0) {
  throw new Error('No usable Telegram accounts found. Did you run moveEnvToRedis and set ENCRYPTION_KEY?');
}

Also applies to: 86-91


112-115: Avoid noisy console logs in library code

Prefer a passed-in logger or debug gating to keep services quiet in production.


71-79: Consider sharing Redis connection with usage utils

fetchAllAccounts uses this.redisClient, but getApiKeyUsage/trackApiKeyUsage use a separate global client. For efficiency and fewer sockets, consider allowing redisUtils to accept an injected client.

Happy to draft a small adapter in redisUtils to accept an optional client instance.

src/index.ts (2)

35-41: Fail fast in non-interactive environments without a session

Interactive prompts will hang cron workers. With the per-account session check in place, also gate start() to avoid hanging.

-  await client.start({
+  await client.start({
     phoneNumber: async () => await input.text("Enter your phone number: "),
     password: async () => await input.text("Enter 2FA password (if enabled): "),
     phoneCode: async () => await input.text("Enter code you received: "),
     onError: (err) => console.log(err),
   });

Add above call:

if (!isInteractive && !sessionStr) {
  throw new Error(`Non-interactive login not possible for ${account.accountId}; provide ${sessionEnvKey}.`);
}

75-87: Cron: handle per-run exceptions without process-wide impact and consider jitter

Current schedule is fine; consider adding random jitter to distribute load and avoid synchronized spikes if multiple workers run.

src/services/twitterAccountManager.ts (5)

99-108: Harden sort against invalid timestamps; treat invalid as “never used”

Date parsing can yield NaN; compare using a safe helper.

-        accounts.sort((a, b) => {
-            if (!a.lastUsed && !b.lastUsed) return 0;
-            if (!a.lastUsed) return -1; // a comes first (never used)
-            if (!b.lastUsed) return 1;  // b comes first (never used)
-
-            // Both have lastUsed dates, compare them
-            return new Date(a.lastUsed).getTime() - new Date(b.lastUsed).getTime();
-        });
+        const ts = (s?: string) => {
+            const t = s ? Date.parse(s) : NaN;
+            return Number.isFinite(t) ? t : -Infinity; // never/invalid used -> earliest
+        };
+        accounts.sort((a, b) => ts(a.lastUsed) - ts(b.lastUsed));

30-35: Connect race: two concurrent callers can both call connect()

Guard with a connecting promise to avoid duplicate connects.

private connecting?: Promise<void>;
private async ensureConnected(): Promise<void> {
  if (this.isConnected) return;
  if (!this.connecting) {
    this.connecting = this.redisClient.connect().then(() => { this.isConnected = true; }).finally(() => { this.connecting = undefined; });
  }
  await this.connecting;
}

48-54: Type the account payload and improve parse diagnostics

Prefer a typed interface over Record to catch missing fields at compile time; include original parse error for debugging.

type EncryptedTwitterAccount = {
  TWITTER_AUTH_TOKEN: string;
  TWITTER_BEARER: string;
  TWITTER_CSRF_TOKEN: string;
  // optional: stable account_label for identity across token rotations
};
let encryptedAccounts: EncryptedTwitterAccount[];
try {
  encryptedAccounts = JSON.parse(raw);
} catch (e) {
  throw new Error(`Failed to parse Twitter accounts from Redis: ${(e as Error).message}`);
}

57-67: Validate required fields before decrypting

Early-validate presence of expected keys; skip entries with clear diagnostics instead of throwing decrypt errors.

if (!encryptedAccount.TWITTER_AUTH_TOKEN || !encryptedAccount.TWITTER_BEARER || !encryptedAccount.TWITTER_CSRF_TOKEN) {
  console.warn(`Skipping account index ${i}: missing required encrypted fields`);
  continue;
}

136-141: Tolerate disconnect errors and pending connects

Wrap quit() and ignore “The client is closed”/ECONNRESET noise; also handle in-flight connect.

async disconnect(): Promise<void> {
  try {
    if (this.isConnected) await this.redisClient.quit();
  } catch {}
  this.isConnected = false;
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d30bae7 and 92b9239.

📒 Files selected for processing (11)
  • src/fetchTelegramMessages.ts (2 hunks)
  • src/index.ts (2 hunks)
  • src/services/telegramAccountManager.ts (1 hunks)
  • src/services/twitterAccountManager.ts (1 hunks)
  • src/tests/rotationDemo.ts (1 hunks)
  • src/tests/telegramRotationDemo.ts (1 hunks)
  • src/tests/testRotation.ts (1 hunks)
  • src/tests/testTelegramRotation.ts (1 hunks)
  • src/twitterApi.ts (4 hunks)
  • src/utils/moveEnvToRedis.ts (3 hunks)
  • src/utils/redisUtils.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (10)
src/tests/rotationDemo.ts (1)
src/services/twitterAccountManager.ts (1)
  • twitterAccountManager (145-145)
src/tests/telegramRotationDemo.ts (1)
src/services/telegramAccountManager.ts (1)
  • telegramAccountManager (145-145)
src/utils/moveEnvToRedis.ts (1)
src/lib/encryption.ts (1)
  • encrypt (20-26)
src/services/twitterAccountManager.ts (2)
src/lib/encryption.ts (1)
  • decrypt (29-41)
src/utils/redisUtils.ts (2)
  • getApiKeyUsage (60-88)
  • trackApiKeyUsage (18-46)
src/tests/testTelegramRotation.ts (1)
src/services/telegramAccountManager.ts (1)
  • telegramAccountManager (145-145)
src/tests/testRotation.ts (1)
src/services/twitterAccountManager.ts (1)
  • twitterAccountManager (145-145)
src/services/telegramAccountManager.ts (2)
src/lib/encryption.ts (1)
  • decrypt (29-41)
src/utils/redisUtils.ts (2)
  • getApiKeyUsage (60-88)
  • trackApiKeyUsage (18-46)
src/fetchTelegramMessages.ts (2)
src/services/telegramAccountManager.ts (1)
  • TelegramAccount (5-14)
src/utils/redisUtils.ts (1)
  • trackApiKeyUsage (18-46)
src/index.ts (2)
src/services/telegramAccountManager.ts (2)
  • TelegramAccount (5-14)
  • telegramAccountManager (145-145)
src/fetchTelegramMessages.ts (1)
  • fetchTelegramMessages (7-62)
src/twitterApi.ts (1)
src/services/twitterAccountManager.ts (2)
  • TwitterAccount (5-14)
  • twitterAccountManager (145-145)
🔇 Additional comments (4)
src/tests/rotationDemo.ts (1)

12-96: Solid demo; clear rotation flow and proper cleanup.

Shows initial/final state, selects earliest-used, marks as used, and always disconnects. LGTM.

src/tests/telegramRotationDemo.ts (1)

12-96: Good demo parity with Twitter; clean lifecycle handling.

Clear rotation illustration and guaranteed disconnect. LGTM.

src/services/twitterAccountManager.ts (2)

40-47: Confirm Redis key for account list

Key name is 'twitter-accounts' here; usage hashes use 'twitter_accounts:'. Verify this is intentional and documented to avoid future drift.


144-145: Singleton lifecycle

Ensure all tests/services call twitterAccountManager.disconnect() on shutdown to avoid dangling connections; consider a factory for DI in tests.

Comment on lines +14 to +16
if (clientMap.has(account.accountId)) {
return clientMap.get(account.accountId)!;
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Reuse only live clients; evict stale ones

If a client was disconnected, you’ll return a dead client. Check connectivity before reuse.

-  if (clientMap.has(account.accountId)) {
-    return clientMap.get(account.accountId)!;
-  }
+  const cached = clientMap.get(account.accountId);
+  if (cached && (cached as any).connected !== false) {
+    return cached;
+  }
+  if (cached && (cached as any).connected === false) {
+    clientMap.delete(account.accountId);
+  }
📝 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
if (clientMap.has(account.accountId)) {
return clientMap.get(account.accountId)!;
}
const cached = clientMap.get(account.accountId);
if (cached && (cached as any).connected !== false) {
return cached;
}
if (cached && (cached as any).connected === false) {
clientMap.delete(account.accountId);
}
🤖 Prompt for AI Agents
In src/index.ts around lines 14 to 16, the code returns a cached client without
verifying its connectivity; change the logic to check the client's live status
before reusing it (e.g., call the client's connection/ready/ping method or
inspect a connected/readyState property), and if the client is not connected
remove it from clientMap and proceed to create or fetch a new live client;
ensure the connectivity check is synchronous/awaited as needed and that evicted
clients are properly cleaned up (closed) before replacement.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
src/index.ts (1)

14-16: Reuse only live clients; evict stale ones

Check connectivity before returning cached clients; evict disconnected ones.

-  if (clientMap.has(account.accountId)) {
-    return clientMap.get(account.accountId)!;
-  }
+  const cached = clientMap.get(account.accountId);
+  if (cached && (cached as any).connected !== false) {
+    return cached;
+  }
+  if (cached && (cached as any).connected === false) {
+    clientMap.delete(account.accountId);
+  }
src/services/twitterAccountManager.ts (1)

158-160: Do not expose decrypted credentials from a “…Usage” method

Return a usage-only projection; provide a separate method when credentials are explicitly needed.

-    async getAllAccountsUsage(): Promise<TwitterAccount[]> {
-        return await this.fetchAllAccounts();
-    }
+    async getAllAccountsUsage(): Promise<Array<{ accountId: string; lastUsed?: string; totalRequests?: number }>> {
+        const accounts = await this.fetchAllAccounts();
+        return accounts.map(({ accountId, lastUsed, totalRequests }) => ({ accountId, lastUsed, totalRequests }));
+    }
+
+    // Optional: expose full info when explicitly required
+    async getAllAccountsWithCredentials(): Promise<TwitterAccount[]> {
+        return await this.fetchAllAccounts();
+    }
🧹 Nitpick comments (5)
src/services/telegramAccountManager.ts (1)

112-115: Gate selected-account logs behind a debug flag

Avoid emitting identifiers in prod logs.

-        console.log(`Selected Telegram account: ${selectedAccount.accountId}`);
-        console.log(`Last used: ${selectedAccount.lastUsed || 'Never'}`);
-        console.log(`Total requests: ${selectedAccount.totalRequests || 0}`);
+        if (process.env.DEBUG_TELEGRAM === '1') {
+          console.debug(`Selected Telegram account: ${selectedAccount.accountId}`);
+          console.debug(`Last used: ${selectedAccount.lastUsed || 'Never'}`);
+          console.debug(`Total requests: ${selectedAccount.totalRequests || 0}`);
+        }
src/fetchTelegramMessages.ts (2)

2-2: Remove unused import

trackApiKeyUsage is not used anymore.

-import { trackApiKeyUsage } from './utils/redisUtils';

54-58: Do not log message contents by default

Gate message logs behind DEBUG_TELEGRAM or drop them.

-      console.log(formatted);
+      if (process.env.DEBUG_TELEGRAM === '1') {
+        console.debug(formatted);
+      }
@@
-    console.log("No messages property found in response:", messages);
+    if (process.env.DEBUG_TELEGRAM === '1') {
+      console.debug("No messages property found in response:", messages);
+    }
src/tests/telegramRotationDemo.ts (1)

23-28: Reduce sensitive console output in demo

Even in demos, consider gating channel/account prints to DEBUG_TELEGRAM to avoid leaking identifiers in CI logs.

-        initialAccounts.forEach((account, index) => {
+        initialAccounts.forEach((account, index) => {
           console.log(`   ${index + 1}. ${account.accountId}`);
           console.log(`      Last used: ${account.lastUsed || 'Never'}`);
           console.log(`      Total requests: ${account.totalRequests || 0}`);
-          console.log(`      Channel: ${account.credentials.TELEGRAM_TG_CHANNEL}`);
+          if (process.env.DEBUG_TELEGRAM === '1') {
+            console.log(`      Channel: ${account.credentials.TELEGRAM_TG_CHANNEL}`);
+          }
         });
@@
-            console.log(`   Channel: ${selectedAccount.credentials.TELEGRAM_TG_CHANNEL}`);
+            if (process.env.DEBUG_TELEGRAM === '1') {
+              console.log(`   Channel: ${selectedAccount.credentials.TELEGRAM_TG_CHANNEL}`);
+            }
@@
-            console.log(`      Channel: ${account.credentials.TELEGRAM_TG_CHANNEL}`);
+            if (process.env.DEBUG_TELEGRAM === '1') {
+              console.log(`      Channel: ${account.credentials.TELEGRAM_TG_CHANNEL}`);
+            }

Also applies to: 39-43, 71-75

src/services/twitterAccountManager.ts (1)

21-23: Align Redis URL defaults with Telegram manager

Fallback to localhost to ease local dev and keep parity.

-        this.redisClient = createClient({
-            url: redisUrl || process.env.REDIS_URL
-        });
+        this.redisClient = createClient({
+            url: redisUrl || process.env.REDIS_URL || 'redis://localhost:6379'
+        });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 92b9239 and 9e85559.

📒 Files selected for processing (6)
  • src/fetchTelegramMessages.ts (1 hunks)
  • src/index.ts (2 hunks)
  • src/services/telegramAccountManager.ts (1 hunks)
  • src/services/twitterAccountManager.ts (1 hunks)
  • src/tests/telegramRotationDemo.ts (1 hunks)
  • src/tests/testTelegramRotation.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/tests/testTelegramRotation.ts
🧰 Additional context used
🧬 Code graph analysis (5)
src/services/twitterAccountManager.ts (1)
src/lib/encryption.ts (1)
  • decrypt (29-41)
src/index.ts (2)
src/services/telegramAccountManager.ts (2)
  • TelegramAccount (5-14)
  • telegramAccountManager (162-162)
src/fetchTelegramMessages.ts (1)
  • fetchTelegramMessages (7-62)
src/services/telegramAccountManager.ts (2)
src/lib/encryption.ts (1)
  • decrypt (29-41)
src/utils/redisUtils.ts (2)
  • getApiKeyUsage (60-88)
  • trackApiKeyUsage (18-46)
src/fetchTelegramMessages.ts (1)
src/services/telegramAccountManager.ts (1)
  • TelegramAccount (5-14)
src/tests/telegramRotationDemo.ts (1)
src/services/telegramAccountManager.ts (1)
  • telegramAccountManager (162-162)

Comment on lines +71 to +79
await fetchTelegramMessages(client, account);

// Show usage statistics for all accounts
const allAccounts = await telegramAccountManager.getAllAccountsUsage();
console.log('All Telegram accounts usage:');
allAccounts.forEach((acc, index) => {
console.log(` Account ${index + 1} (${acc.accountId}):`);
console.log(` Total requests: ${acc.totalRequests}`);
console.log(` Last used: ${acc.lastUsed || 'Never'}`);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Rotation does not update usage — account will never advance

You never call markAccountAsUsed; LRU will keep choosing the same account.

   await fetchTelegramMessages(client, account);
+  await telegramAccountManager.markAccountAsUsed(account.accountId);
@@
   await fetchTelegramMessages(client, account);
-  console.log(`Fetched messages using account: ${account.accountId}`);
+  await telegramAccountManager.markAccountAsUsed(account.accountId);
+  console.log(`Fetched messages using account: ${account.accountId}`);

Also applies to: 92-94

🤖 Prompt for AI Agents
In src/index.ts around lines 71 to 79 (and similarly around 92 to 94), the code
prints per-account usage but never updates each account's usage state, so the
LRU rotation won't advance; call
telegramAccountManager.markAccountAsUsed(account.accountId) (or the appropriate
method) after selecting or displaying an account to update its
lastUsed/totalRequests fields and persist the change; ensure you await the
markAccountAsUsed call where necessary and handle any errors so the usage store
reflects the update immediately.

Comment on lines 1 to 4
import { createClient } from 'redis';
import { decrypt } from '../lib/encryption';
import { getApiKeyUsage, trackApiKeyUsage } from '../utils/redisUtils';

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Unify Redis usage: stop mixing this class’s client with utils’ global client

fetchAllAccounts reads via this.redisClient but usage is read/written via utils’ separate client, risking split state across different Redis URLs. Keep all reads/writes on this.redisClient.

-import { getApiKeyUsage, trackApiKeyUsage } from '../utils/redisUtils';
+// use class-local Redis ops for consistency

@@
-                // Get usage statistics from Redis
-                const usage = await getApiKeyUsage({ accountId, platform: 'telegram' });
+                // Get usage statistics from Redis (same client)
+                const usage = await this.getApiKeyUsageLocal(accountId);

@@
-    async markAccountAsUsed(accountId: string): Promise<void> {
-        await this.trackApiKeyUsageLocal(accountId);
-        // Best-effort unlock; lock has TTL as a safety net
-        try { await this.redisClient.del(`lock:telegram:${accountId}`); } catch { }
-    }
+    async markAccountAsUsed(accountId: string): Promise<void> {
+        await this.trackApiKeyUsageLocal(accountId);
+        try { await this.redisClient.del(`lock:telegram:${accountId}`); } catch { }
+    }

@@
-    private async trackApiKeyUsageLocal(accountId: string): Promise<void> {
-        await trackApiKeyUsage({ accountId, platform: 'telegram' });
-    }
+    private async trackApiKeyUsageLocal(accountId: string): Promise<void> {
+        await this.ensureConnected();
+        const key = `telegram_accounts:${accountId}`;
+        const now = new Date().toISOString();
+        await this.redisClient
+          .multi()
+          .hIncrBy(key, 'total_requests', 1)
+          .hSet(key, { last_request: now, account_id: accountId })
+          .exec();
+    }
+
+    private async getApiKeyUsageLocal(accountId: string): Promise<{ total_requests: number; last_request: string | null }> {
+        await this.ensureConnected();
+        const key = `telegram_accounts:${accountId}`;
+        const data = await this.redisClient.hGetAll(key);
+        return {
+            total_requests: data?.total_requests ? parseInt(data.total_requests, 10) : 0,
+            last_request: data?.last_request ?? null,
+        };
+    }

Also applies to: 71-79, 122-126, 131-133

🤖 Prompt for AI Agents
In src/services/telegramAccountManager.ts around lines 1-4, 71-79, 122-126 and
131-133, the code mixes this class's this.redisClient with the utils module's
global Redis client (getApiKeyUsage/trackApiKeyUsage), which can cause split
state; update the file so all Redis operations use this.redisClient only: either
(preferred) change calls to getApiKeyUsage/trackApiKeyUsage to pass
this.redisClient (and update those helpers to accept a client param), or replace
those helper calls with equivalent operations directly on this.redisClient
(using await this.redisClient.get/ set/ hget/hset/ incr as needed), remove
reliance on the global client, and ensure the client passed is the same
this.redisClient for reads and writes throughout the listed line ranges.

Comment on lines 96 to 117
async getEarliestUsedAccount(): Promise<TelegramAccount> {
const accounts = await this.fetchAllAccounts();

// Sort accounts by last_request timestamp (earliest first)
// Accounts with no last_request (never used) come first
accounts.sort((a, b) => {
if (!a.lastUsed && !b.lastUsed) return 0;
if (!a.lastUsed) return -1; // a comes first (never used)
if (!b.lastUsed) return 1; // b comes first (never used)

// Both have lastUsed dates, compare them
return new Date(a.lastUsed).getTime() - new Date(b.lastUsed).getTime();
});

const selectedAccount = accounts[0];

console.log(`Selected Telegram account: ${selectedAccount.accountId}`);
console.log(`Last used: ${selectedAccount.lastUsed || 'Never'}`);
console.log(`Total requests: ${selectedAccount.totalRequests || 0}`);

return selectedAccount;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add an atomic claim/lock to prevent same-account selection under concurrency

Multiple workers can select the same LRU account; you also attempt to delete a non-existent lock in markAccountAsUsed. Claim with SET NX PX and fall through to the next candidate.

-        const selectedAccount = accounts[0];
-
-        console.log(`Selected Telegram account: ${selectedAccount.accountId}`);
-        console.log(`Last used: ${selectedAccount.lastUsed || 'Never'}`);
-        console.log(`Total requests: ${selectedAccount.totalRequests || 0}`);
-
-        return selectedAccount;
+        for (const acc of accounts) {
+            const lockKey = `lock:telegram:${acc.accountId}`;
+            const ok = await this.redisClient.set(lockKey, '1', { NX: true, PX: 15000 });
+            if (ok === 'OK') {
+                if (process.env.DEBUG_TELEGRAM === '1') {
+                  console.debug(`[TelegramAccountManager] Selected account=${acc.accountId} lastUsed=${acc.lastUsed ?? 'Never'} totalRequests=${acc.totalRequests ?? 0}`);
+                }
+                return acc;
+            }
+        }
+        throw new Error('No available Telegram accounts to claim (all locked).');
📝 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
async getEarliestUsedAccount(): Promise<TelegramAccount> {
const accounts = await this.fetchAllAccounts();
// Sort accounts by last_request timestamp (earliest first)
// Accounts with no last_request (never used) come first
accounts.sort((a, b) => {
if (!a.lastUsed && !b.lastUsed) return 0;
if (!a.lastUsed) return -1; // a comes first (never used)
if (!b.lastUsed) return 1; // b comes first (never used)
// Both have lastUsed dates, compare them
return new Date(a.lastUsed).getTime() - new Date(b.lastUsed).getTime();
});
const selectedAccount = accounts[0];
console.log(`Selected Telegram account: ${selectedAccount.accountId}`);
console.log(`Last used: ${selectedAccount.lastUsed || 'Never'}`);
console.log(`Total requests: ${selectedAccount.totalRequests || 0}`);
return selectedAccount;
}
async getEarliestUsedAccount(): Promise<TelegramAccount> {
const accounts = await this.fetchAllAccounts();
// Sort accounts by last_request timestamp (earliest first)
// Accounts with no last_request (never used) come first
accounts.sort((a, b) => {
if (!a.lastUsed && !b.lastUsed) return 0;
if (!a.lastUsed) return -1; // a comes first (never used)
if (!b.lastUsed) return 1; // b comes first (never used)
// Both have lastUsed dates, compare them
return new Date(a.lastUsed).getTime() - new Date(b.lastUsed).getTime();
});
for (const acc of accounts) {
const lockKey = `lock:telegram:${acc.accountId}`;
const ok = await this.redisClient.set(lockKey, '1', { NX: true, PX: 15000 });
if (ok === 'OK') {
if (process.env.DEBUG_TELEGRAM === '1') {
console.debug(`[TelegramAccountManager] Selected account=${acc.accountId} lastUsed=${acc.lastUsed ?? 'Never'} totalRequests=${acc.totalRequests ?? 0}`);
}
return acc;
}
}
throw new Error('No available Telegram accounts to claim (all locked).');
}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/index.ts (1)

87-99: Align lock TTL to job duration, add jitter/backoff, and fix Telegram locking

  • Observations: Twitter and the shared BaseAccountManager acquire locks with PX:15000 (15s). src/services/twitterAccountManager.ts and src/services/BaseAccountManager.ts use set(..., { NX: true, PX: 15000 }). src/services/telegramAccountManager.ts's getEarliestUsedAccount does NOT acquire a lock (it returns accounts[0]); markAccountAsUsed still deletes lock:telegram:${accountId}.
  • Risk: If client creation/fetching or downstream processing exceeds 15s another worker can steal the same account mid-run; Telegram selection is racy because it has no acquisition step.
  • Required fixes:
    • Increase lock TTL to cover worst-case job runtime (or implement lease-renewal).
    • Use an owner token when setting locks and release only if the token matches (or use a safe Lua script / Redlock) to avoid accidental unlocks.
    • Replace immediate "all locked -> throw" behavior with a retry loop using exponential backoff + random jitter to avoid thundering-herd and improve resilience.
    • Add equivalent locking to Telegram selection (or make selection atomic) so markAccountAsUsed's unlock matches an actual acquisition.
  • Locations to change: src/services/BaseAccountManager.ts (getEarliestUsedAccount lock set), src/services/twitterAccountManager.ts (getEarliestUsedAccount lock set), src/services/telegramAccountManager.ts (getEarliestUsedAccount — add atomic lock acquisition; markAccountAsUsed unlock semantics).
♻️ Duplicate comments (3)
src/index.ts (3)

14-16: Ensure cached Telegram client is live; evict/reconnect stale clients

Reusing a disconnected client will fail unpredictably. Check connectivity, try reconnect, else evict and recreate.

-  if (clientMap.has(account.accountId)) {
-    return clientMap.get(account.accountId)!;
-  }
+  const cached = clientMap.get(account.accountId);
+  if (cached) {
+    try {
+      if ((cached as any).connected === true) return cached;
+      await (cached as any).connect?.();
+      return cached;
+    } catch {
+      try { await (cached as any).disconnect?.(); } catch {}
+      clientMap.delete(account.accountId);
+    }
+  }

70-74: LRU will not advance without marking usage

Call markAccountAsUsed after a successful fetch; otherwise the same account is repeatedly selected.

     const client = await createTelegramClient(account);
 
-    await fetchTelegramMessages(client, account);
+    await fetchTelegramMessages(client, account);
+    await telegramAccountManager.markAccountAsUsed(account.accountId);
       const client = await createTelegramClient(account);
 
-      await fetchTelegramMessages(client, account);
+      await fetchTelegramMessages(client, account);
+      await telegramAccountManager.markAccountAsUsed(account.accountId);
       console.log(`Fetched messages using account: ${account.accountId}`);

Also applies to: 94-96


52-57: Don’t print raw session; persist to Redis and log a masked notice

The export ${sessionKey}=... is both an invalid env var (contains “:”) and leaks a secret. Save to Redis and avoid dumping the full token.

-  console.log(`Logged in successfully for account: ${account.accountId}`);
-  const saved = client.session.save();
-  if (process.env.PRINT_TG_SESSION === "1" && isInteractive) {
-    // Emit an export-ready line deliberately, instead of dumping secrets in logs
-    console.log(`export ${sessionKey}="${saved}"`);
-  }
+  console.log(`Logged in successfully for account: ${account.accountId}`);
+  const saved = client.session.save();
+  try { await (telegramAccountManager as any).redisClient.set(sessionKey, saved); } catch {}
+  if (process.env.PRINT_TG_SESSION === "1" && isInteractive) {
+    const preview = saved.slice(0, 8) + "…";
+    console.log(`Saved session for ${account.accountId} to Redis key "${sessionKey}" (${preview})`);
+  }
🧹 Nitpick comments (4)
src/index.ts (1)

31-33: Don’t reach into manager internals; guard Redis connect with isOpen

Accessing telegramAccountManager.redisClient via as any breaks encapsulation and can double-connect. Expose a session helper on the manager, or minimally guard connect().

-  const redisClient = (telegramAccountManager as any).redisClient;
-  await redisClient.connect?.();
+  const redisClient = (telegramAccountManager as any).redisClient;
+  if (redisClient?.isOpen !== true) {
+    await redisClient.connect();
+  }
src/services/BaseAccountManager.ts (3)

37-42: Be resilient to invalid lastUsed values
new Date(invalid).getTime() yields NaN. Treat invalid/missing as oldest.

-            return new Date(a.lastUsed).getTime() - new Date(b.lastUsed).getTime();
+            const ta = Number.isFinite(Date.parse(a.lastUsed!)) ? Date.parse(a.lastUsed!) : 0;
+            const tb = Number.isFinite(Date.parse(b.lastUsed!)) ? Date.parse(b.lastUsed!) : 0;
+            return ta - tb;

21-23: Constructor logging shouldn’t rely on abstract field
this.platform may be undefined during super(); prefer constructor name.

-        this.redisClient.on('error', (err) => {
-            console.error(`Redis Client Error in ${this.platform}AccountManager:`, err);
-        });
+        this.redisClient.on('error', (err) => {
+            console.error(`Redis Client Error in ${this.constructor.name}:`, err);
+        });

61-66: Use client state instead of shadow isConnected
Rely on redisClient.isOpen to avoid drift between flags.

-        if (this.isConnected) {
-            await this.redisClient.quit();
-            this.isConnected = false;
-        }
+        if (this.redisClient?.isOpen) {
+            await this.redisClient.quit();
+            this.isConnected = false;
+        }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9e85559 and 5d212e0.

📒 Files selected for processing (3)
  • src/index.ts (2 hunks)
  • src/services/BaseAccountManager.ts (1 hunks)
  • src/utils/moveEnvToRedis.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/utils/moveEnvToRedis.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/index.ts (2)
src/services/telegramAccountManager.ts (2)
  • TelegramAccount (5-14)
  • telegramAccountManager (162-162)
src/fetchTelegramMessages.ts (1)
  • fetchTelegramMessages (7-62)
🔇 Additional comments (1)
src/index.ts (1)

23-28: API credentials validation looks good
Input validation for apiId and apiHash is correct and provides account-scoped error messages.

Comment on lines +36 to 41
if (!sessionStr && !isInteractive) {
throw new Error(`Missing session in Redis for ${account.accountId} (key: ${sessionKey}). Generate and store a session string before running cron.`);
}
sessionStr = sessionStr || "";
const stringSession = new StringSession(sessionStr);
const client = new TelegramClient(stringSession, apiId, apiHash, {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Non‑interactive runs may hang on prompts; prefer connect+health‑check

When isInteractive is false and a bad/expired session is loaded, start() will try to prompt and hang. Use connect() and a light auth check; only fall back to start() if interactive.

-  await client.start({
-    phoneNumber: async () => await input.text("Enter your phone number: "),
-    password: async () => await input.text("Enter 2FA password (if enabled): "),
-    phoneCode: async () => await input.text("Enter code you received: "),
-    onError: (err) => console.log(err),
-  });
+  if (sessionStr) {
+    await client.connect();
+    try {
+      await client.getMe(); // basic auth/health check
+    } catch (e) {
+      if (!isInteractive) {
+        throw new Error(`Session for ${account.accountId} is invalid/expired; re-auth interactively to refresh.`);
+      }
+      await client.start({
+        phoneNumber: async () => await input.text("Enter your phone number: "),
+        password: async () => await input.text("Enter 2FA password (if enabled): "),
+        phoneCode: async () => await input.text("Enter code you received: "),
+        onError: (err) => console.log(err),
+      });
+    }
+  } else {
+    await client.start({
+      phoneNumber: async () => await input.text("Enter your phone number: "),
+      password: async () => await input.text("Enter 2FA password (if enabled): "),
+      phoneCode: async () => await input.text("Enter code you received: "),
+      onError: (err) => console.log(err),
+    });
+  }

Also applies to: 45-50

Comment on lines +45 to +47
const ok = await this.redisClient.set(lockKey, '1', { NX: true, PX: 15000 });
if (ok === 'OK') {
console.debug(`[${this.platform}AccountManager] Selected account=${acc.accountId} lastUsed=${acc.lastUsed ?? 'Never'} totalRequests=${acc.totalRequests ?? 0}`);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Unsafe lock release; use token‑based delete (Redlock semantics)
Plain DEL can remove another process’s lock if TTL expired and was reacquired. Store a random token as the lock value and conditionally delete if the token matches.

+    private lockTokens = new Map<string, string>();
@@
-            const ok = await this.redisClient.set(lockKey, '1', { NX: true, PX: 15000 });
+            const token = crypto.randomUUID();
+            const ok = await this.redisClient.set(lockKey, token, { NX: true, PX: 15000 });
             if (ok === 'OK') {
+                this.lockTokens.set(acc.accountId, token);
                 console.debug(`[${this.platform}AccountManager] Selected account=${acc.accountId} lastUsed=${acc.lastUsed ?? 'Never'} totalRequests=${acc.totalRequests ?? 0}`);
                 return acc;
             }
@@
-        await this.trackApiKeyUsageLocal(accountId);
-        try { await this.redisClient.del(`lock:${this.platform}:${accountId}`); } catch { }
+        await this.trackApiKeyUsageLocal(accountId);
+        const lockKey = `lock:${this.platform}:${accountId}`;
+        const token = this.lockTokens.get(accountId) ?? '';
+        // Delete only if value matches our token
+        try {
+            await this.redisClient.eval(
+                'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
+                { keys: [lockKey], arguments: [token] }
+            );
+        } catch {}
+        this.lockTokens.delete(accountId);

Alternatively, use the redlock package for robustness.

npm i redlock

Also applies to: 54-57

🤖 Prompt for AI Agents
In src/services/BaseAccountManager.ts around lines 45-47 (and similarly at
54-57), the code uses a plain DEL to release a lock which can remove another
process’s lock after TTL expiry; change the lock to store a
cryptographically-random token as the value when acquiring (SET key token NX PX
...), and when releasing perform an atomic check-and-delete (compare current
value to token and delete only if equal) — implement this using a small Redis
EVAL Lua script or replace the custom locking with the redlock package (npm i
redlock) to get correct token-based release semantics.

…countManager for improved Redis integration and connection handling
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (4)
src/services/telegramAccountManager.ts (1)

1-4: Stop mixing Redis clients; use this.redisClient everywhere (duplicate).

Replace utils getApiKeyUsage/trackApiKeyUsage with class‑local helpers using this.redisClient to avoid split state across Redis URLs.

Apply these diffs:

@@
-import { getApiKeyUsage, trackApiKeyUsage } from '../utils/redisUtils';
+// use class-local Redis ops for consistency
@@
-                // Get usage statistics from Redis
-                const usage = await getApiKeyUsage({ accountId, platform: 'telegram' });
+                // Get usage statistics from Redis (same client)
+                const usage = await this.getApiKeyUsageLocal(accountId);
@@
-    protected async trackApiKeyUsageLocal(accountId: string): Promise<void> {
-        await trackApiKeyUsage({ accountId, platform: 'telegram' });
-    }
+    protected async trackApiKeyUsageLocal(accountId: string): Promise<void> {
+        await this.ensureConnected();
+        const key = `${this.usageKeyPrefix}:${accountId}`;
+        const now = new Date().toISOString();
+        await this.redisClient
+          .multi()
+          .hIncrBy(key, 'total_requests', 1)
+          .hSet(key, { last_request: now, account_id: accountId })
+          .exec();
+    }

Add this helper (outside the shown ranges):

private async getApiKeyUsageLocal(accountId: string): Promise<{ total_requests: number; last_request: string | null }> {
  await this.ensureConnected();
  const key = `${this.usageKeyPrefix}:${accountId}`;
  const data = await this.redisClient.hGetAll(key);
  return {
    total_requests: data?.total_requests ? parseInt(data.total_requests, 10) : 0,
    last_request: data?.last_request ?? null,
  };
}

Run to confirm no remaining utils usage in services:

#!/bin/bash
rg -nP --type=ts -C2 '\b(getApiKeyUsage|trackApiKeyUsage)\b' src/services
src/services/twitterAccountManager.ts (1)

110-112: Do not expose decrypted credentials from getAllAccountsUsage (duplicate).

Return a usage-only projection and add a separate API for credentials.

-    async getAllAccountsUsage(): Promise<TwitterAccount[]> {
-        return await this.fetchAllAccounts();
-    }
+    async getAllAccountsUsage(): Promise<Array<{ accountId: string; lastUsed?: string; totalRequests?: number }>> {
+        const accounts = await this.fetchAllAccounts();
+        return accounts.map(({ accountId, lastUsed, totalRequests }) => ({ accountId, lastUsed, totalRequests }));
+    }

Add (outside shown range):

async getAllAccountsWithCredentials(): Promise<TwitterAccount[]> {
  return await this.fetchAllAccounts();
}
src/services/BaseAccountManager.ts (2)

45-47: Acquire locks with per-account random tokens (Redlock semantics).

Use a random token as the lock value and store it for safe release.

-            const lockKey = `lock:${this.platform}:${acc.accountId}`;
-            const ok = await this.redisClient.set(lockKey, '1', { NX: true, PX: 15000 });
+            const lockKey = `lock:${this.platform}:${acc.accountId}`;
+            const token = (globalThis.crypto?.randomUUID?.() ?? require('crypto').randomUUID());
+            const ok = await this.redisClient.set(lockKey, token, { NX: true, PX: 15000 });
             if (ok === 'OK') {
-                console.debug(`[${this.platform}AccountManager] Selected account=${acc.accountId} lastUsed=${acc.lastUsed ?? 'Never'} totalRequests=${acc.totalRequests ?? 0}`);
+                this.lockTokens.set(acc.accountId, token);
+                console.debug(`[${this.platform}AccountManager] Selected account=${acc.accountId} lastUsed=${acc.lastUsed ?? 'Never'} totalRequests=${acc.totalRequests ?? 0}`);
                 return acc;
             }

Add class field (outside shown range):

private lockTokens = new Map<string, string>();

55-59: Release locks safely and always (token check + finally).

-    async markAccountAsUsed(accountId: string): Promise<void> {
-        await this.ensureConnected();
-        await this.trackApiKeyUsageLocal(accountId);
-        try { await this.redisClient.del(`lock:${this.platform}:${accountId}`); } catch { }
-    }
+    async markAccountAsUsed(accountId: string): Promise<void> {
+        await this.ensureConnected();
+        const lockKey = `lock:${this.platform}:${accountId}`;
+        const token = this.lockTokens.get(accountId) ?? '';
+        try {
+            await this.trackApiKeyUsageLocal(accountId);
+        } finally {
+            try {
+                await this.redisClient.eval(
+                  'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
+                  { keys: [lockKey], arguments: [token] }
+                );
+            } catch {}
+            this.lockTokens.delete(accountId);
+        }
+    }
🧹 Nitpick comments (11)
src/services/telegramAccountManager.ts (4)

36-41: Harden JSON parsing and validate shape.

-        try {
-            encryptedAccounts = JSON.parse(raw);
-        } catch (e) {
-            throw new Error('Failed to parse Telegram accounts from Redis');
-        }
+        try {
+            encryptedAccounts = JSON.parse(raw);
+            if (!Array.isArray(encryptedAccounts)) {
+                throw new Error('telegram-accounts must be a JSON array');
+            }
+        } catch (e) {
+            throw new Error('Failed to parse Telegram accounts from Redis');
+        }

45-67: Reduce N+1 Redis roundtrips when loading usage.

Decrypt all accounts first, then pipeline hGetAll for usage with multi()/exec() and map replies back to accounts to cut latency.


56-58: Optional: avoid using raw TELEGRAM_API_ID in accountId.

For parity with Twitter and to avoid exposing identifiers in keys/logs, derive accountId from a hash of the API ID (e.g., sha256(...).slice(0,12)).


19-20: usageKeyPrefix is unused here; either use it or remove it for clarity.

src/services/twitterAccountManager.ts (3)

36-41: Validate parsed structure from Redis.

-        try {
-            encryptedAccounts = JSON.parse(raw);
-        } catch (e) {
-            throw new Error('Failed to parse Twitter accounts from Redis');
-        }
+        try {
+            encryptedAccounts = JSON.parse(raw);
+            if (!Array.isArray(encryptedAccounts)) {
+                throw new Error('twitter-accounts must be a JSON array');
+            }
+        } catch (e) {
+            throw new Error('Failed to parse Twitter accounts from Redis');
+        }

45-73: Pipeline usage reads to avoid per-account awaits.

After decrypting and computing accountIds, batch hGetAll calls via multi()/exec() and merge results to reduce latency under many accounts.


19-20: usageKeyPrefix declared but not used.

Use it in getApiKeyUsageLocal/trackApiKeyUsageLocal key construction or drop it.

src/services/BaseAccountManager.ts (4)

38-43: Guard against invalid lastUsed timestamps in sort.

-        accounts.sort((a, b) => {
+        accounts.sort((a, b) => {
             if (!a.lastUsed && !b.lastUsed) return 0;
             if (!a.lastUsed) return -1;
             if (!b.lastUsed) return 1;
-            return new Date(a.lastUsed).getTime() - new Date(b.lastUsed).getTime();
+            const ta = Date.parse(a.lastUsed);
+            const tb = Date.parse(b.lastUsed);
+            if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
+            if (Number.isNaN(ta)) return 1;
+            if (Number.isNaN(tb)) return -1;
+            return ta - tb;
         });

15-16: Abstract usageKeyPrefix is never used.

Either remove it from the base and subclasses, or refactor local usage helpers to build keys from it for consistency.


63-69: Minor: swallow-only catch hides unlock failures.

Log at debug level to aid troubleshooting (without spamming):

-        if (this.isConnected) {
+        if (this.isConnected) {
             await this.redisClient.quit();
             this.isConnected = false;
         }

And prefer:

try { /* unlock */ } catch (e) { console.debug('unlock failed', e); }

45-47: Operational: consider ZSET-based true LRU for scale.

Model accounts in a sorted set keyed by last_used and atomically claim via LUA or ZPOPMIN/ZADD to avoid scanning/sorting full lists and reduce lock contention.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d212e0 and 2f2f23c.

📒 Files selected for processing (3)
  • src/services/BaseAccountManager.ts (1 hunks)
  • src/services/telegramAccountManager.ts (1 hunks)
  • src/services/twitterAccountManager.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/services/twitterAccountManager.ts (2)
src/services/BaseAccountManager.ts (1)
  • BaseAccount (3-8)
src/lib/encryption.ts (1)
  • decrypt (29-41)
src/services/telegramAccountManager.ts (3)
src/services/BaseAccountManager.ts (1)
  • BaseAccount (3-8)
src/lib/encryption.ts (1)
  • decrypt (29-41)
src/utils/redisUtils.ts (2)
  • getApiKeyUsage (60-88)
  • trackApiKeyUsage (18-46)
🔇 Additional comments (2)
src/services/telegramAccountManager.ts (1)

92-95: Good: usage-only projection avoids leaking credentials.

src/services/twitterAccountManager.ts (1)

56-59: Good: stable, non-reversible accountId via SHA‑256.

@tasin2610 tasin2610 self-requested a review September 19, 2025 10:06
@tasin2610 tasin2610 merged commit 4817c91 into main Sep 19, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants