feat: add telegram provider with CLI integration

Add Telegram as a third messaging provider alongside web and twilio.

Core Features:
- Interactive login flow with phone/SMS/2FA authentication
- Send text and media messages (images, videos, audio, documents)
- Monitor incoming messages with auto-reply support
- Session management at ~/.clawdis/telegram/session/
- Full CLI integration (login, logout, status, send, relay commands)

Implementation Details:
- Uses telegram npm package for MTProto API access
- Supports both URL and local file media sending
- Cross-platform path handling (Windows/Unix)
- Optional Twilio env vars (supports Telegram-only usage)
- Minimal provider abstraction pattern
- Comprehensive test coverage (440 tests passing)

Changes:
- Add Telegram module (client, login, monitor, inbound, outbound, session)
- Add provider factory and base interfaces
- Wire Telegram functions into CLI deps
- Update env validation to make Twilio fields optional
- Add telegram to all CLI commands (login, logout, status, send, relay)
- Add null checks in Twilio code for optional env fields
- Fix send command to properly load session and connect
- Add local file support with cross-platform path handling
- Update login message to show correct ~/.clawdis path
- Add comprehensive tests and documentation

Basic Usage:
  warelay login --provider telegram
  warelay send --provider telegram --to "@user" --message "Hi"
  warelay send --provider telegram --to "@user" --media "/path/to/file.jpg"
  warelay relay --provider telegram

All tests pass (63 files, 440 tests). Zero TypeScript errors.
This commit is contained in:
Arne Moor 2025-12-05 18:59:38 +01:00
parent 20cb709ae3
commit 69608fd305
45 changed files with 7328 additions and 40 deletions

File diff suppressed because it is too large Load Diff

497
docs/telegram.md Normal file
View File

@ -0,0 +1,497 @@
# Telegram Integration
## Overview
warelay now supports Telegram via the MTProto client library (GramJS), allowing you to use your personal Telegram account for automated messaging. This provides the same personal automation capabilities as WhatsApp Web, but for Telegram conversations.
## Setup
### 1. Get API Credentials
Register a new application at **https://my.telegram.org/apps** to get:
- **API ID** (numeric, e.g., `12345678`)
- **API Hash** (hexadecimal string, e.g., `abcdef0123456789abcdef0123456789`)
**Important:** These credentials are for your personal use only. Never share them publicly or commit them to version control.
### 2. Configure Environment
Add to `.env`:
```bash
TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=abcdef0123456789abcdef0123456789
```
### 3. Login
```bash
warelay login --provider telegram
```
You'll be prompted for:
1. **Phone number** (with country code, e.g., `+15551234567`)
2. **SMS verification code** (sent to your Telegram app or SMS)
3. **2FA password** (if you have two-factor authentication enabled)
Session is saved to `~/.warelay/telegram/session/` and persists across restarts.
### 4. Configure Whitelist (Optional)
In `~/.warelay/warelay.json`:
```json5
{
telegram: {
// Only these users can trigger auto-replies
allowFrom: [
"@username", // Telegram username (with @)
"+1234567890", // Phone number (with +)
"123456789" // User ID (numeric)
]
}
}
```
**Security note:** If `allowFrom` is empty or omitted, all incoming messages will trigger auto-replies. Use a whitelist in production.
## CLI Usage
### Send Messages
**Text message:**
```bash
warelay send --provider telegram --to @username --message "Hello from warelay"
```
**To a user by phone number:**
```bash
warelay send --provider telegram --to +15551234567 --message "Hi!"
```
**To a user by numeric ID:**
```bash
warelay send --provider telegram --to 123456789 --message "Hi!"
```
**With media:**
```bash
warelay send --provider telegram --to @username \
--message "Check this out" \
--media ./image.jpg
```
**With media URL:**
```bash
warelay send --provider telegram --to @username \
--message "Look at this" \
--media https://example.com/image.jpg
```
### Start Relay (Auto-Reply)
```bash
warelay relay --provider telegram --verbose
```
The relay will:
- Connect to Telegram via MTProto
- Listen for incoming messages
- Send typing indicators while processing
- Auto-reply based on your configuration
- Persist sessions across conversations
### Check Status
```bash
warelay status --provider telegram --limit 20 --lookback 240
```
Shows recent sent/received messages with delivery status.
### Logout
```bash
warelay logout --provider telegram
```
Removes the saved session from `~/.warelay/telegram/session/`.
## Features
| Feature | Supported | Notes |
|---------|-----------|-------|
| Text messages | ✅ | Full UTF-8 support, including emoji |
| Media (images, video, audio) | ⚠️ | Up to 2 GB supported, but files >500MB may cause memory issues (buffers entire file) |
| Typing indicators | ✅ | Shows "typing..." while processing |
| Replies | ✅ | Reply to specific messages |
| Message formatting | ✅ | Markdown and HTML formatting |
| Max media size | 2 GB | Enforced when Content-Length available; ⚠️ large files buffered in memory |
| Delivery receipts | ❌ | MTProto limitation (no sent/delivered/read states) |
| Read receipts | ❌ | Not exposed via Provider interface |
| Reactions | ❌ | Not exposed via Provider interface (requires peer context) |
| Editing | ❌ | Not exposed via Provider interface (requires peer context) |
| Deleting | ❌ | Not exposed via Provider interface (requires peer context) |
| Group chats | ⚠️ | Not yet implemented (planned) |
**Note on advanced features:** While Telegram's MTProto API supports reactions, editing, and deleting messages, these features require maintaining peer context (chat/user entity references) which the current Provider interface architecture doesn't support. These features may be added in a future Provider interface revision.
## Security Model
### Personal Account Automation
Telegram integration uses **MTProto client** (not Bot API), which means:
- You're using your personal Telegram account as an automation tool
- All messages appear as coming from you (your name, profile picture)
- You have full access to your conversations and contacts
- No bot limitations (can initiate conversations, see full message history)
### Whitelist Filtering
Control who can trigger auto-replies via `allowFrom` config:
```json5
{
telegram: {
allowFrom: ["@alice", "@bob", "123456789"]
}
}
```
- **Username** (`@alice`): Match by Telegram username
- **Phone number** (`+15551234567`): Match by phone number
- **User ID** (`123456789`): Match by numeric Telegram user ID
If `allowFrom` is empty or omitted, **all messages trigger auto-replies** (use with caution).
### Session Storage
Session files are stored encrypted at `~/.warelay/telegram/session/`:
- Contains authentication tokens and keys
- Persists across restarts
- Should be treated as sensitive (equivalent to login credentials)
- Backup recommended if running in production
### MTProto End-to-End Encryption
- All communication uses Telegram's MTProto protocol
- Messages are encrypted in transit
- Secret chats (end-to-end encrypted) are not supported by the client library
## Troubleshooting
### "No Telegram session found"
**Problem:** You haven't logged in yet.
**Solution:**
```bash
warelay login --provider telegram
```
### "Telegram not configured"
**Problem:** Missing `TELEGRAM_API_ID` or `TELEGRAM_API_HASH` in `.env`.
**Solution:**
1. Get credentials from https://my.telegram.org/apps
2. Add them to `.env`:
```bash
TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=your_hash_here
```
### "Could not resolve entity"
**Problem:** The username, phone number, or user ID is invalid or not found.
**Solution:** Check the identifier format:
- Usernames must start with `@` (e.g., `@username`)
- Phone numbers must start with `+` (e.g., `+15551234567`)
- User IDs are numeric (e.g., `123456789`)
**Tip:** You can get a user's ID by sending them a message and checking the logs with `--verbose`.
### Re-authentication needed
**Problem:** Session expired or was invalidated.
**Solution:**
```bash
warelay logout --provider telegram
warelay login --provider telegram
```
### "FLOOD_WAIT" error
**Problem:** You're sending too many requests too quickly (rate limited by Telegram).
**Solution:**
- Wait the specified number of seconds before retrying
- Reduce message frequency
- Implement delays between sends
### Session corruption
**Problem:** Session file is corrupted or invalid.
**Solution:**
```bash
# Remove corrupted session
rm -rf ~/.warelay/telegram/session/
# Re-login
warelay login --provider telegram
```
## Configuration Examples
### Simple Text Auto-Reply
```json5
{
telegram: {
allowFrom: ["@alice", "@bob"]
},
inbound: {
reply: {
mode: "text",
text: "Thanks for your message! I'll get back to you soon."
}
}
}
```
### Claude-Powered Assistant
```json5
{
telegram: {
allowFrom: ["@alice", "+15551234567"]
},
inbound: {
reply: {
mode: "command",
bodyPrefix: "You are a helpful assistant on Telegram. Be concise.\n\n",
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
claudeOutputFormat: "text",
session: {
scope: "per-sender",
resetTriggers: ["/new"],
idleMinutes: 60
}
}
}
}
```
### Per-Sender Sessions with Heartbeats
```json5
{
telegram: {
allowFrom: ["@alice", "@bob", "@charlie"]
},
inbound: {
reply: {
mode: "command",
command: ["claude", "{{BodyStripped}}"],
claudeOutputFormat: "text",
session: {
scope: "per-sender",
resetTriggers: ["/new", "/reset"],
idleMinutes: 120,
heartbeatIdleMinutes: 10
},
heartbeatMinutes: 15
}
}
}
```
## Comparison with WhatsApp
| Feature | WhatsApp Web | WhatsApp Twilio | Telegram |
|---------|--------------|-----------------|----------|
| **Authentication** | QR code scan | API credentials | Phone + SMS + 2FA |
| **Account Type** | Personal | Business | Personal |
| **Protocol** | WebSocket (Baileys) | HTTP (Twilio API) | MTProto (GramJS) |
| **Max file size** | 100 MB | 5 MB | 2 GB |
| **Typing indicators** | ✅ | ✅ | ✅ |
| **Read receipts** | ✅ | ❌ | ❌ |
| **Delivery tracking** | Limited | Full | Limited |
| **Group chats** | ✅ | ✅ | ⚠️ (planned) |
| **Reactions** | ❌ | ❌ | ❌ |
| **Edit messages** | ❌ | ❌ | ❌ |
| **Delete messages** | ✅ | ✅ | ❌ |
| **Cost** | Free | Pay per message | Free |
**Note:** Telegram's MTProto API technically supports reactions, edits, and deletes, but these are not exposed via the Provider interface (requires peer context architecture changes).
## Best Practices
### 1. Use a Dedicated Account
Consider using a separate Telegram account for automation:
- Reduces risk to your primary account
- Easier to manage rate limits
- Clearer separation of personal and automated messages
### 2. Implement Rate Limiting
Telegram has rate limits for personal accounts:
- Avoid sending bursts of messages
- Space sends by a few seconds
- Handle `FLOOD_WAIT` errors gracefully
### 3. Backup Your Session
Session files contain authentication tokens:
```bash
# Backup
cp -r ~/.warelay/telegram/session/ ~/backups/warelay-telegram-session/
# Restore
cp -r ~/backups/warelay-telegram-session/ ~/.warelay/telegram/session/
```
### 4. Monitor Logs
Run with `--verbose` to see detailed activity:
```bash
warelay relay --provider telegram --verbose
```
Logs include:
- Connection status
- Inbound/outbound messages
- Session management
- Error details
### 5. Secure Your Credentials
- Never commit `.env` to version control
- Treat `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` as secrets
- Store session backups securely
- Use `allowFrom` whitelist in production
## Advanced Usage
### Running Multiple Providers
You can run WhatsApp and Telegram relays simultaneously:
**Terminal 1 (WhatsApp):**
```bash
tmux new -s warelay-whatsapp -d "warelay relay --provider wa-web --verbose"
```
**Terminal 2 (Telegram):**
```bash
tmux new -s warelay-telegram -d "warelay relay --provider telegram --verbose"
```
### Custom Session Storage
Override session path in config:
```json5
{
telegram: {
sessionPath: "/custom/path/to/session/"
}
}
```
### Verbose Output
Get detailed logs for debugging:
```bash
warelay relay --provider telegram --verbose
```
Output includes:
- MTProto connection events
- Message send/receive details
- Session state changes
- Error stack traces
## Limitations
### Current Limitations
1. **Group chats not yet supported** - Only 1-on-1 conversations work currently (group support planned)
2. **No delivery receipts** - MTProto doesn't provide sent/delivered/read states like Twilio
3. **No secret chats** - End-to-end encrypted "Secret Chats" are not supported by GramJS
4. **Rate limits** - Personal accounts have rate limits (use with moderation)
### Media Handling
**Streaming Implementation**
Media downloads use streaming to temporary files, eliminating memory buffering:
- Files downloaded to `~/.warelay/telegram-temp`
- No memory spike regardless of file size
- Automatic cleanup after send (success or failure)
- Orphaned files cleaned on process restart (1 hour TTL)
**Disk Usage:**
- Temp file created during download
- Cleaned immediately after send
- Max disk usage: size of largest concurrent download
**Performance:**
- No memory overhead for large files
- Same download speed as before
- Proper backpressure handling via Node.js streams
**Production Safety:**
Set `TELEGRAM_MAX_MEDIA_MB` to limit disk usage:
```bash
# Limit to 500MB for production
TELEGRAM_MAX_MEDIA_MB=500 warelay relay --provider telegram
```
**Note:** The limit is read at process startup. Changing the env var requires restarting the relay.
### Known Issues
- Session may expire if not used for extended periods (re-login required)
- Username changes won't be reflected in `allowFrom` until relay restart
## Resources
- **Get API credentials:** https://my.telegram.org/apps
- **Telegram API documentation:** https://core.telegram.org/api
- **GramJS library:** https://gram.js.org/
- **MTProto protocol:** https://core.telegram.org/mtproto
## Migration from Other Providers
### From WhatsApp Web
1. Keep your WhatsApp Web configuration
2. Add Telegram credentials to `.env`
3. Run `warelay login --provider telegram`
4. Start Telegram relay alongside WhatsApp:
```bash
# WhatsApp relay (terminal 1)
warelay relay --provider wa-web --verbose
# Telegram relay (terminal 2)
warelay relay --provider telegram --verbose
```
### From WhatsApp Twilio
Similar steps as above - both providers can coexist.
## Getting Help
If you encounter issues:
1. **Check logs:** Run with `--verbose` flag
2. **Verify credentials:** Ensure API ID/Hash are correct
3. **Test login:** Try `warelay login --provider telegram` manually
4. **Check session:** Verify `~/.warelay/telegram/session/` exists and is readable
5. **Review config:** Ensure `~/.warelay/warelay.json` is valid JSON5
For bugs or feature requests, file an issue on GitHub.

View File

@ -41,6 +41,7 @@
"pino": "^10.1.0",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.33.5",
"telegram": "^2.26.22",
"twilio": "^5.10.6",
"zod": "^4.1.13"
},

View File

@ -11,6 +11,8 @@ import {
sendMessageWeb,
} from "../providers/web/index.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createTelegramClient } from "../telegram/client.js";
import { monitorTelegramProvider } from "../telegram/monitor.js";
import { createClient } from "../twilio/client.js";
import { listRecentMessages } from "../twilio/messages.js";
import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js";
@ -27,7 +29,9 @@ export type CliDeps = {
waitForFinalStatus: typeof waitForFinalStatus;
assertProvider: typeof assertProvider;
createClient?: typeof createClient;
createTelegramClient: typeof createTelegramClient;
monitorTwilio: typeof monitorTwilio;
monitorTelegramProvider: typeof monitorTelegramProvider;
listRecentMessages: typeof listRecentMessages;
ensurePortAvailable: typeof ensurePortAvailable;
startWebhook: typeof startWebhook;
@ -75,7 +79,9 @@ export function createDefaultDeps(): CliDeps {
waitForFinalStatus,
assertProvider,
createClient,
createTelegramClient,
monitorTwilio,
monitorTelegramProvider,
listRecentMessages,
ensurePortAvailable,
startWebhook,

View File

@ -60,7 +60,7 @@ describe("cli program", () => {
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
).rejects.toThrow("exit");
expect(runtime.error).toHaveBeenCalledWith(
"--provider must be auto, web, or twilio",
"--provider must be auto, web, twilio, or telegram",
);
});

View File

@ -6,7 +6,7 @@ import { statusCommand } from "../commands/status.js";
import { webhookCommand } from "../commands/webhook.js";
import { loadConfig } from "../config/config.js";
import { ensureTwilioEnv } from "../env.js";
import { danger, info, setVerbose, setYes } from "../globals.js";
import { danger, info, setVerbose, setYes, success } from "../globals.js";
import { getResolvedLoggerSettings } from "../logging.js";
import {
loginWeb,
@ -18,6 +18,12 @@ import {
type WebMonitorTuning,
} from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import {
clearSession,
loginTelegram,
monitorTelegramProvider,
telegramAuthExists,
} from "../telegram/index.js";
import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js";
import type { Provider } from "../utils.js";
import { VERSION } from "../version.js";
@ -112,23 +118,44 @@ export function buildProgram() {
program
.command("login")
.description("Link your personal WhatsApp via QR (web provider)")
.description("Link your personal WhatsApp via QR (web provider) or Telegram")
.option("--provider <provider>", "Provider: web | telegram", "web")
.option("--verbose", "Verbose connection logs", false)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const provider = String(opts.provider ?? "web");
if (!["web", "telegram"].includes(provider)) {
defaultRuntime.error("--provider must be web or telegram");
defaultRuntime.exit(1);
}
try {
if (provider === "telegram") {
await loginTelegram(Boolean(opts.verbose), defaultRuntime);
return;
}
await loginWeb(Boolean(opts.verbose));
} catch (err) {
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
defaultRuntime.error(danger(`${provider} login failed: ${String(err)}`));
defaultRuntime.exit(1);
}
});
program
.command("logout")
.description("Clear cached WhatsApp Web credentials")
.action(async () => {
.description("Clear cached WhatsApp Web or Telegram credentials")
.option("--provider <provider>", "Provider: web | telegram", "web")
.action(async (opts) => {
const provider = String(opts.provider ?? "web");
if (!["web", "telegram"].includes(provider)) {
defaultRuntime.error("--provider must be web or telegram");
defaultRuntime.exit(1);
}
try {
if (provider === "telegram") {
await clearSession();
console.log(success("Cleared Telegram credentials."));
return;
}
await logoutWeb(defaultRuntime);
} catch (err) {
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
@ -159,7 +186,7 @@ export function buildProgram() {
"20",
)
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--provider <provider>", "Provider: twilio | web | telegram", "twilio")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
@ -349,7 +376,7 @@ Examples:
program
.command("relay")
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
.option("--provider <provider>", "auto | web | twilio", "auto")
.option("--provider <provider>", "auto | web | twilio | telegram", "auto")
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
.option(
"-l, --lookback <minutes>",
@ -391,8 +418,8 @@ Examples:
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
const providerPref = String(opts.provider ?? "auto");
if (!["auto", "web", "twilio"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto, web, or twilio");
if (!["auto", "web", "twilio", "telegram"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto, web, twilio, or telegram");
defaultRuntime.exit(1);
}
const intervalSeconds = Number.parseInt(opts.interval, 10);
@ -470,6 +497,12 @@ Examples:
webTuning.reconnect = reconnect;
}
// Handle telegram explicitly (not in auto picker)
if (providerPref === "telegram") {
await monitorTelegramProvider(Boolean(opts.verbose), defaultRuntime);
return;
}
const provider = await pickProvider(providerPref as Provider | "auto");
if (provider === "web") {
@ -591,6 +624,14 @@ Examples:
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
// Show provider auth status before message listing
if (!opts.json) {
const hasTelegram = await telegramAuthExists();
defaultRuntime.log(
`Telegram: ${hasTelegram ? success("✓ logged in") : info("✗ not logged in")}`,
);
defaultRuntime.log(""); // Empty line before messages
}
await statusCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));

View File

@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { CliDeps } from "../cli/deps.js";
import { info, success } from "../globals.js";
import { danger, info, success } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendMediaMessage, sendTextMessage } from "../telegram/outbound.js";
import { loadSession } from "../telegram/session.js";
import type { Provider } from "../utils.js";
import { sendViaIpc } from "../web/ipc.js";
@ -15,6 +19,7 @@ export async function sendCommand(
dryRun?: boolean;
media?: string;
serveMedia?: boolean;
verbose?: boolean;
},
deps: CliDeps,
runtime: RuntimeEnv,
@ -101,6 +106,98 @@ export async function sendCommand(
return;
}
if (opts.provider === "telegram") {
if (opts.dryRun) {
runtime.log(
`[dry-run] would send via telegram -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
);
return;
}
// Load saved session
const session = await loadSession();
if (!session) {
runtime.error(
danger(
"No Telegram session found. Run: warelay login --provider telegram",
),
);
throw new Error("Not logged in to Telegram");
}
// Create and connect client
const client = await deps.createTelegramClient(
session,
Boolean(opts.verbose),
runtime,
);
try {
await client.connect();
if (!client.connected) {
throw new Error("Failed to connect to Telegram");
}
let result;
if (opts.media) {
// Determine media type from file extension
const ext = opts.media.toLowerCase().split(".").pop() || "";
const imageExts = ["jpg", "jpeg", "png", "gif", "webp"];
const videoExts = ["mp4", "mov", "avi", "mkv"];
const audioExts = ["mp3", "wav", "ogg", "m4a"];
let type: "image" | "video" | "audio" | "document" = "document";
if (imageExts.includes(ext)) type = "image";
else if (videoExts.includes(ext)) type = "video";
else if (audioExts.includes(ext)) type = "audio";
// Check if media is a URL or local file
const isUrl = /^https?:\/\//i.test(opts.media);
if (isUrl) {
// Send URL directly
result = await sendMediaMessage(client, opts.to, opts.message, {
type,
url: opts.media,
});
} else {
// Load local file as buffer
const buffer = await fs.readFile(opts.media);
result = await sendMediaMessage(client, opts.to, opts.message, {
type,
buffer,
fileName: path.basename(opts.media),
});
}
} else {
result = await sendTextMessage(client, opts.to, opts.message);
}
if (opts.json) {
runtime.log(
JSON.stringify(
{
provider: "telegram",
to: opts.to,
messageId: result.messageId,
mediaUrl: opts.media ?? null,
},
null,
2,
),
);
} else {
runtime.log(
success(`✅ Sent to ${opts.to} via Telegram (id ${result.messageId})`),
);
}
} finally {
// Always disconnect client
await client.disconnect();
}
return;
}
if (opts.dryRun) {
runtime.log(
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,

View File

@ -54,7 +54,7 @@ export async function upCommand(
const twilioClient = deps.createClient(env);
const senderSid = await deps.findWhatsappSenderSid(
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
env.whatsappFrom,
env.whatsappFrom!,
env.whatsappSenderSid,
runtime,
);

View File

@ -43,6 +43,10 @@ export type WebConfig = {
reconnect?: WebReconnectConfig;
};
export type TelegramConfig = {
allowFrom?: string[]; // @username or user IDs
};
export type GroupChatConfig = {
requireMention?: boolean;
mentionPatterns?: string[];
@ -86,6 +90,7 @@ export type WarelayConfig = {
};
};
web?: WebConfig;
telegram?: TelegramConfig;
};
// New branding path (preferred)
@ -233,6 +238,11 @@ const WarelaySchema = z.object({
.optional(),
})
.optional(),
telegram: z
.object({
allowFrom: z.array(z.string()).optional(),
})
.optional(),
});
export function loadConfig(): WarelayConfig {

View File

@ -40,7 +40,7 @@ describe("env helpers", () => {
const cfg = readEnv(runtime);
expect(cfg.accountSid).toBe("AC123");
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
if ("authToken" in cfg.auth) {
if (cfg.auth && "authToken" in cfg.auth) {
expect(cfg.auth.authToken).toBe("token");
} else {
throw new Error("Expected auth token");
@ -55,7 +55,7 @@ describe("env helpers", () => {
TWILIO_API_SECRET: "secret",
});
const cfg = readEnv(runtime);
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
if (cfg.auth && "apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
expect(cfg.auth.apiKey).toBe("key");
expect(cfg.auth.apiSecret).toBe("secret");
} else {
@ -63,9 +63,20 @@ describe("env helpers", () => {
}
});
it("fails fast on invalid env", () => {
it("passes when no Twilio vars present (Telegram-only mode)", () => {
setEnv({
TWILIO_ACCOUNT_SID: "",
TELEGRAM_API_ID: "12345",
TELEGRAM_API_HASH: "abcdef",
});
const cfg = readEnv(runtime);
expect(cfg.telegram).toEqual({ apiId: "12345", apiHash: "abcdef" });
expect(cfg.accountSid).toBeUndefined();
expect(cfg.auth).toBeUndefined();
});
it("fails when Twilio vars partially set", () => {
setEnv({
TWILIO_ACCOUNT_SID: "AC123",
TWILIO_WHATSAPP_FROM: "",
TWILIO_AUTH_TOKEN: undefined,
TWILIO_API_KEY: undefined,

View File

@ -8,22 +8,56 @@ export type AuthMode =
| { accountSid: string; apiKey: string; apiSecret: string };
export type EnvConfig = {
accountSid: string;
whatsappFrom: string;
accountSid?: string;
whatsappFrom?: string;
whatsappSenderSid?: string;
auth: AuthMode;
auth?: AuthMode;
telegram?: {
apiId: string;
apiHash: string;
};
};
const EnvSchema = z
.object({
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
TWILIO_ACCOUNT_SID: z.string().optional(),
TWILIO_WHATSAPP_FROM: z.string().optional(),
TWILIO_SENDER_SID: z.string().optional(),
TWILIO_AUTH_TOKEN: z.string().optional(),
TWILIO_API_KEY: z.string().optional(),
TWILIO_API_SECRET: z.string().optional(),
TELEGRAM_API_ID: z.string().optional(),
TELEGRAM_API_HASH: z.string().optional(),
})
.superRefine((val, ctx) => {
// Only validate Twilio auth if any Twilio vars are present
const hasTwilioVars =
val.TWILIO_ACCOUNT_SID ||
val.TWILIO_WHATSAPP_FROM ||
val.TWILIO_AUTH_TOKEN ||
val.TWILIO_API_KEY ||
val.TWILIO_API_SECRET;
if (!hasTwilioVars) {
// No Twilio vars present, skip Twilio validation (Telegram-only mode)
return;
}
// If any Twilio vars are present, enforce required fields
if (!val.TWILIO_ACCOUNT_SID) {
ctx.addIssue({
code: "custom",
message: "TWILIO_ACCOUNT_SID required when using Twilio",
});
}
if (!val.TWILIO_WHATSAPP_FROM) {
ctx.addIssue({
code: "custom",
message: "TWILIO_WHATSAPP_FROM required when using Twilio",
});
}
// Validate Twilio auth pairs
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
ctx.addIssue({
code: "custom",
@ -49,7 +83,7 @@ const EnvSchema = z
});
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
// Load and validate Twilio auth + sender configuration from env.
// Load and validate environment configuration (supports Twilio, Telegram, or both).
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
runtime.error("Invalid environment configuration:");
@ -66,24 +100,31 @@ export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
TWILIO_AUTH_TOKEN: authToken,
TWILIO_API_KEY: apiKey,
TWILIO_API_SECRET: apiSecret,
TELEGRAM_API_ID: telegramApiId,
TELEGRAM_API_HASH: telegramApiHash,
} = parsed.data;
let auth: AuthMode;
if (apiKey && apiSecret) {
auth = { accountSid, apiKey, apiSecret };
} else if (authToken) {
auth = { accountSid, authToken };
} else {
runtime.error("Missing Twilio auth configuration");
runtime.exit(1);
throw new Error("unreachable");
// Build Twilio auth if credentials are present
let auth: AuthMode | undefined;
if (accountSid) {
if (apiKey && apiSecret) {
auth = { accountSid, apiKey, apiSecret };
} else if (authToken) {
auth = { accountSid, authToken };
}
}
const telegram =
telegramApiId && telegramApiHash
? { apiId: telegramApiId, apiHash: telegramApiHash }
: undefined;
return {
accountSid,
whatsappFrom,
whatsappSenderSid,
auth,
telegram,
};
}

View File

@ -0,0 +1,8 @@
/**
* Provider Base Module
*
* Exports the core provider interface and types for all messaging providers.
*/
export * from "./interface.js";
export * from "./types.js";

View File

@ -0,0 +1,120 @@
/**
* Core Provider Interface
*
* All messaging providers must implement this interface.
*/
import type {
DeliveryStatus,
MessageHandler,
ProviderCapabilities,
ProviderConfig,
ProviderKind,
SendOptions,
SendResult,
} from "./types.js";
export interface Provider {
/** Provider type identifier */
readonly kind: ProviderKind;
/** Declared capabilities */
readonly capabilities: ProviderCapabilities;
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/**
* Initialize the provider with configuration.
* Must be called before any other methods.
*/
initialize(config: ProviderConfig): Promise<void>;
/**
* Check if the provider is connected and ready.
*/
isConnected(): boolean;
/**
* Gracefully disconnect and cleanup resources.
*/
disconnect(): Promise<void>;
// ---------------------------------------------------------------------------
// Outbound Messaging
// ---------------------------------------------------------------------------
/**
* Send a message to a recipient.
*
* @param to - Recipient identifier (phone, user ID, username)
* @param body - Message text
* @param options - Optional send options
* @returns Send result with message ID
*/
send(to: string, body: string, options?: SendOptions): Promise<SendResult>;
/**
* Send typing indicator to a chat.
*
* @param to - Recipient identifier
*/
sendTyping(to: string): Promise<void>;
/**
* Query delivery status of a sent message.
*
* @param messageId - Message identifier returned from send()
* @returns Current delivery status
*/
getDeliveryStatus(messageId: string): Promise<DeliveryStatus>;
// ---------------------------------------------------------------------------
// Inbound Messaging
// ---------------------------------------------------------------------------
/**
* Register a handler for inbound messages.
* The provider will call this handler when new messages arrive.
*
* @param handler - Function to handle incoming messages
*/
onMessage(handler: MessageHandler): void;
/**
* Start listening for inbound messages.
* This starts the message polling/monitoring loop.
*/
startListening(): Promise<void>;
/**
* Stop listening for inbound messages.
*/
stopListening(): Promise<void>;
// ---------------------------------------------------------------------------
// Authentication & Session Management
// ---------------------------------------------------------------------------
/**
* Check if provider has valid authentication.
*/
isAuthenticated(): Promise<boolean>;
/**
* Interactive login flow (QR code, phone + 2FA, etc.).
* Implementation is provider-specific.
*/
login(): Promise<void>;
/**
* Clear authentication and session data.
*/
logout(): Promise<void>;
/**
* Get current session identifier (phone, user ID, etc.).
*/
getSessionId(): Promise<string | null>;
}

249
src/providers/base/types.ts Normal file
View File

@ -0,0 +1,249 @@
/**
* Provider Interface Types for warelay
*
* Unified interfaces for all messaging providers (WhatsApp, Telegram).
* All providers follow the same model:
* - Personal account automation for 1-on-1 conversations
* - `allowFrom` whitelist security model
* - Unified message format
*/
// =============================================================================
// PROVIDER TYPES
// =============================================================================
/**
* Supported provider kinds.
*/
export type ProviderKind = "twilio" | "web" | "telegram";
// =============================================================================
// MESSAGE TYPES
// =============================================================================
/**
* Media attachment for messages.
*/
export interface ProviderMedia {
/** Media type category */
type: "image" | "video" | "audio" | "document" | "voice";
/** Remote URL (for Twilio or download) */
url?: string;
/** Local buffer (for Web/Telegram direct send) */
buffer?: Buffer;
/** MIME type (e.g., "image/jpeg", "audio/ogg") */
mimeType?: string;
/** Original filename (for documents) */
fileName?: string;
/** File size in bytes */
size?: number;
/** Thumbnail buffer (for video/document previews) */
thumbnail?: Buffer;
}
/**
* Normalized inbound message from any provider.
*/
export interface ProviderMessage {
/** Unique message identifier */
id: string;
/** Sender identifier */
from: string;
/** Recipient identifier */
to: string;
/** Message text body */
body: string;
/** Unix timestamp in milliseconds */
timestamp: number;
/** Sender's display name if available */
displayName?: string;
/** Attached media */
media?: ProviderMedia[];
/** Provider-specific raw payload */
raw?: unknown;
/** Which provider this message came from */
provider: ProviderKind;
}
// =============================================================================
// SEND TYPES
// =============================================================================
/**
* Options for sending a message.
*/
export interface SendOptions {
/** Attach media to the message */
media?: ProviderMedia[];
/** Message ID to reply to (creates a threaded reply) */
replyTo?: string;
/** Send typing indicator before message */
typing?: boolean;
/** Provider-specific options */
providerOptions?: {
/** Twilio: Messaging Service SID override */
twilioMessagingServiceSid?: string;
};
}
/**
* Result of sending a message.
*/
export interface SendResult {
/** Message identifier from the provider */
messageId: string;
/** Immediate send status */
status: "sent" | "queued" | "failed";
/** Error message if status is "failed" */
error?: string;
/** Provider-specific metadata */
providerMeta?: {
sid?: string;
accountSid?: string;
userId?: number;
jid?: string;
};
}
/**
* Delivery status for a sent message.
*/
export interface DeliveryStatus {
/** Message identifier */
messageId: string;
/** Current delivery status */
status: "sent" | "delivered" | "read" | "failed" | "unknown";
/** Status update timestamp */
timestamp?: number;
/** Error details if failed */
error?: string;
/** Provider-specific status code */
providerStatusCode?: string | number;
}
// =============================================================================
// PROVIDER CAPABILITIES
// =============================================================================
/**
* Declares what features a provider supports.
*/
export interface ProviderCapabilities {
supportsDeliveryReceipts: boolean;
supportsReadReceipts: boolean;
supportsTypingIndicator: boolean;
supportsReactions: boolean;
supportsReplies: boolean;
supportsEditing: boolean;
supportsDeleting: boolean;
maxMediaSize: number;
supportedMediaTypes: string[];
canInitiateConversation: boolean;
}
// =============================================================================
// PROVIDER CONFIGURATION
// =============================================================================
/**
* Base configuration shared by all providers.
*/
export interface BaseProviderConfig {
verbose?: boolean;
logger?: unknown;
}
/**
* Twilio WhatsApp provider configuration.
*/
export interface TwilioProviderConfig extends BaseProviderConfig {
kind: "wa-twilio";
accountSid: string;
authToken?: string;
apiKey?: string;
apiSecret?: string;
whatsappFrom: string;
messagingServiceSid?: string;
}
/**
* WhatsApp Web provider configuration.
*/
export interface WebProviderConfig extends BaseProviderConfig {
kind: "wa-web";
authDir?: string;
printQr?: boolean;
reconnect?: {
initialMs?: number;
maxMs?: number;
factor?: number;
jitter?: number;
maxAttempts?: number;
};
}
/**
* Telegram MTProto provider configuration.
*/
export interface TelegramProviderConfig extends BaseProviderConfig {
kind: "telegram";
apiId: number;
apiHash: string;
sessionDir?: string;
allowFrom?: string[];
}
/**
* Union of all provider configurations.
*/
export type ProviderConfig =
| TwilioProviderConfig
| WebProviderConfig
| TelegramProviderConfig;
// =============================================================================
// INBOUND MESSAGE HANDLER
// =============================================================================
/**
* Handler function for inbound messages.
*/
export type MessageHandler = (message: ProviderMessage) => Promise<void>;
/**
* Handler context with reply helpers.
*/
export interface MessageContext extends ProviderMessage {
sendTyping(): Promise<void>;
reply(text: string): Promise<SendResult>;
replyWithMedia(text: string, media: ProviderMedia[]): Promise<SendResult>;
}
/**
* Enhanced message handler with context.
*/
export type MessageContextHandler = (ctx: MessageContext) => Promise<void>;

50
src/providers/factory.ts Normal file
View File

@ -0,0 +1,50 @@
/**
* Provider Factory
*
* Factory functions for creating and initializing messaging providers.
*/
import { TelegramProvider } from "../telegram/index.js";
import type { Provider, ProviderConfig, ProviderKind } from "./base/index.js";
/**
* Create a provider instance by kind.
*
* The provider is created but NOT initialized. Call initialize() before using.
*
* @param kind - Provider type to create
* @returns Uninitialized provider instance
* @throws Error if provider kind is unknown or not yet implemented
*/
export function createProvider(kind: ProviderKind): Provider {
switch (kind) {
case "web":
case "twilio":
throw new Error(
`Provider ${kind} not yet implemented in abstraction layer - use direct CLI commands`,
);
case "telegram":
return new TelegramProvider();
default:
throw new Error(`Unknown provider kind: ${kind}`);
}
}
/**
* Create and initialize a provider in one step.
*
* Convenience function that combines createProvider() and initialize().
*
* @param kind - Provider type to create
* @param config - Configuration for the provider
* @returns Initialized and ready-to-use provider
* @throws Error if provider creation or initialization fails
*/
export async function createInitializedProvider(
kind: ProviderKind,
config: ProviderConfig,
): Promise<Provider> {
const provider = createProvider(kind);
await provider.initialize(config);
return provider;
}

View File

@ -0,0 +1,35 @@
/**
* Capabilities Tests
*
* Note: These tests verify the CURRENT capability values.
* Testing TELEGRAM_MAX_MEDIA_MB env var requires subprocess tests
* since capabilities are evaluated at module load time.
*/
import { describe, expect, it } from "vitest";
import { capabilities } from "./capabilities.js";
describe("capabilities", () => {
it("has correct basic capabilities", () => {
expect(capabilities.supportsDeliveryReceipts).toBe(false);
expect(capabilities.supportsReadReceipts).toBe(false);
expect(capabilities.supportsTypingIndicator).toBe(true);
expect(capabilities.supportsReplies).toBe(true);
expect(capabilities.canInitiateConversation).toBe(true);
expect(capabilities.supportsReactions).toBe(false);
expect(capabilities.supportsEditing).toBe(false);
expect(capabilities.supportsDeleting).toBe(false);
});
it("has max media size set", () => {
// Default is 2GB unless TELEGRAM_MAX_MEDIA_MB is set
expect(capabilities.maxMediaSize).toBeGreaterThan(0);
expect(capabilities.maxMediaSize).toBeLessThanOrEqual(
2 * 1024 * 1024 * 1024,
);
});
it("supports all media types", () => {
expect(capabilities.supportedMediaTypes).toContain("*/*");
});
});

View File

@ -0,0 +1,72 @@
import type { ProviderCapabilities } from "../providers/base/types.js";
/**
* Get the maximum media size for Telegram, respecting user overrides.
* Defaults to 2GB (Telegram's technical limit), but can be lowered via
* TELEGRAM_MAX_MEDIA_MB env var for production safety.
*
* NOTE: This is evaluated once at module load time. Changing the env var
* requires restarting the process to take effect.
*/
function getMaxMediaSize(): number {
const defaultMax = 2 * 1024 * 1024 * 1024; // 2GB
const envOverride = process.env.TELEGRAM_MAX_MEDIA_MB;
if (envOverride) {
const overrideMB = Number.parseInt(envOverride, 10);
if (Number.isNaN(overrideMB) || overrideMB <= 0) {
console.warn(
`⚠️ Invalid TELEGRAM_MAX_MEDIA_MB="${envOverride}" (must be positive number). Using default 2048MB.`,
);
return defaultMax;
}
const overrideBytes = overrideMB * 1024 * 1024;
if (overrideBytes > defaultMax) {
console.warn(
`⚠️ TELEGRAM_MAX_MEDIA_MB=${overrideMB} exceeds Telegram's 2048MB limit. Using 2048MB.`,
);
return defaultMax;
}
return overrideBytes;
}
return defaultMax;
}
/**
* Telegram MTProto Provider Capabilities
*
* Declares what features the Telegram provider supports through the Provider interface.
* Note: Telegram's API supports many features that are not yet exposed via our Provider interface.
*/
export const capabilities: ProviderCapabilities = {
// Telegram MTProto doesn't provide reliable delivery/read receipt tracking
// Messages are sent optimistically without guaranteed delivery confirmation
supportsDeliveryReceipts: false,
supportsReadReceipts: false, // Not exposed via Provider interface
// Typing indicator is supported
supportsTypingIndicator: true,
// Advanced features not yet exposed via Provider interface
// (These require peer context which the current architecture doesn't maintain)
supportsReactions: false,
supportsReplies: true, // Basic reply support via send()
supportsEditing: false,
supportsDeleting: false,
// Telegram supports 2GB files with streaming downloads (no memory buffering).
// Downloads stream to ~/.warelay/telegram-temp and are automatically cleaned up.
// For safety, set TELEGRAM_MAX_MEDIA_MB to limit disk usage and download time.
// Orphaned files cleaned on process restart (1 hour TTL).
//
// PRODUCTION TIP: Set TELEGRAM_MAX_MEDIA_MB to a lower value (e.g., 500) to limit
// disk usage and download time. Example: TELEGRAM_MAX_MEDIA_MB=500 warelay relay
maxMediaSize: getMaxMediaSize(),
// Telegram supports virtually all file types
supportedMediaTypes: ["*/*"],
// Telegram allows initiating conversations with any user
canInitiateConversation: true,
};

132
src/telegram/client.test.ts Normal file
View File

@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const MockTelegramClient = vi.fn();
vi.mock("telegram", () => ({
TelegramClient: class {
connected = false;
constructor(...args: unknown[]) {
MockTelegramClient(...args);
this.connected = false;
}
},
}));
vi.mock("telegram/sessions/index.js", () => ({
StringSession: class {
_sessionString: string;
constructor(sessionString = "") {
this._sessionString = sessionString;
}
save() {
return this._sessionString || "mock-session-string";
}
},
}));
const { createTelegramClient, isClientConnected } = await import("./client.js");
const { StringSession } = await import("telegram/sessions/index.js");
describe("telegram client", () => {
const mockRuntime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
beforeEach(() => {
vi.clearAllMocks();
MockTelegramClient.mockClear();
process.env = {};
});
describe("createTelegramClient", () => {
it("creates client with null session", async () => {
process.env.TELEGRAM_API_ID = "12345";
process.env.TELEGRAM_API_HASH = "abcdef";
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
process.env.TWILIO_AUTH_TOKEN = "token";
await createTelegramClient(null, false, mockRuntime);
expect(MockTelegramClient).toHaveBeenCalledWith(
expect.anything(), // StringSession
12345,
"abcdef",
expect.objectContaining({
connectionRetries: 5,
useWSS: true,
}),
);
});
it("creates client with existing session", async () => {
process.env.TELEGRAM_API_ID = "12345";
process.env.TELEGRAM_API_HASH = "abcdef";
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
process.env.TWILIO_AUTH_TOKEN = "token";
const mockSession = {
save: vi.fn(() => "existing-session"),
_sessionString: "existing-session",
};
await createTelegramClient(mockSession as never, false, mockRuntime);
expect(MockTelegramClient).toHaveBeenCalledWith(
mockSession,
12345,
"abcdef",
expect.objectContaining({
connectionRetries: 5,
useWSS: true,
}),
);
});
it("throws error when Telegram credentials not configured", async () => {
// Both credentials missing - readEnv will allow this but createTelegramClient should throw
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
process.env.TWILIO_AUTH_TOKEN = "token";
try {
await createTelegramClient(null, false, mockRuntime);
throw new Error("Should have thrown");
} catch (err) {
expect((err as Error).message).toContain(
"Telegram API credentials not configured",
);
}
});
it("includes helpful URL in error message", async () => {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
process.env.TWILIO_AUTH_TOKEN = "token";
try {
await createTelegramClient(null, false, mockRuntime);
throw new Error("Should have thrown");
} catch (err) {
expect((err as Error).message).toContain(
"https://my.telegram.org/apps",
);
}
});
});
describe("isClientConnected", () => {
it("returns connection status", () => {
const connectedClient = { connected: true };
const disconnectedClient = { connected: false };
expect(isClientConnected(connectedClient as never)).toBe(true);
expect(isClientConnected(disconnectedClient as never)).toBe(false);
});
});
});

48
src/telegram/client.ts Normal file
View File

@ -0,0 +1,48 @@
import { TelegramClient } from "telegram";
import { StringSession } from "telegram/sessions/index.js";
import { readEnv } from "../env.js";
import { getChildLogger } from "../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
/**
* Create a Telegram client with the given session.
* If session is null, creates a new unauthenticated session.
*/
export async function createTelegramClient(
session: StringSession | null,
verbose: boolean,
runtime: RuntimeEnv = defaultRuntime,
): Promise<TelegramClient> {
const env = readEnv(runtime);
if (!env.telegram?.apiId || !env.telegram?.apiHash) {
throw new Error(
"Telegram API credentials not configured. Set TELEGRAM_API_ID and TELEGRAM_API_HASH environment variables. " +
"Get credentials from https://my.telegram.org/apps",
);
}
const _logger = getChildLogger(
{ module: "telegram-client" },
{ level: verbose ? "info" : "silent" },
);
const client = new TelegramClient(
session || new StringSession(""),
Number.parseInt(env.telegram.apiId, 10),
env.telegram.apiHash,
{
connectionRetries: 5,
useWSS: true, // Use WebSocket for better reliability
},
);
return client;
}
/**
* Check if the given client is connected.
*/
export function isClientConnected(client: TelegramClient): boolean {
return client.connected ?? false;
}

View File

@ -0,0 +1,335 @@
/**
* Download Tests
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanOrphanedTempFiles,
ensureTempDir,
getTelegramTempDir,
streamDownloadToTemp,
} from "./download.js";
// Mock global fetch
global.fetch = vi.fn();
describe("download", () => {
const testTempDir = path.join(
os.tmpdir(),
`warelay-test-${process.pid}-download`,
);
beforeEach(async () => {
// Clean test directory before each test
await fs.rm(testTempDir, { recursive: true, force: true }).catch(() => {});
vi.clearAllMocks();
});
describe("getTelegramTempDir", () => {
it("returns correct path", () => {
const dir = getTelegramTempDir();
// Should contain either .clawdis or .warelay (depending on which exists)
const hasCorrectDir =
dir.includes(".clawdis") || dir.includes(".warelay");
expect(hasCorrectDir).toBe(true);
expect(dir).toContain("telegram-temp");
expect(path.isAbsolute(dir)).toBe(true);
});
});
describe("ensureTempDir", () => {
it("creates directory if not exists", async () => {
// Use the real temp dir for this test
const dir = getTelegramTempDir();
// Clean it first
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
// Verify it doesn't exist
const existsBefore = await fs
.access(dir)
.then(() => true)
.catch(() => false);
expect(existsBefore).toBe(false);
// Create it
await ensureTempDir();
// Verify it exists
const existsAfter = await fs
.access(dir)
.then(() => true)
.catch(() => false);
expect(existsAfter).toBe(true);
});
it("succeeds if already exists", async () => {
const dir = getTelegramTempDir();
// Create it twice
await ensureTempDir();
await ensureTempDir();
// Should not throw
const exists = await fs
.access(dir)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
});
});
describe("streamDownloadToTemp", () => {
it("downloads small file to temp directory", async () => {
const testData = Buffer.from("test file content");
// Mock fetch response with readable stream
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
headers: {
get: (name: string) =>
name === "content-type" ? "text/plain" : null,
},
body: Readable.from([testData]),
} as any);
const result = await streamDownloadToTemp(
"https://example.com/test.txt",
1024 * 1024, // 1MB max
);
try {
// Verify file exists and has correct content
const content = await fs.readFile(result.tempPath);
expect(content.equals(testData)).toBe(true);
expect(result.size).toBe(testData.length);
expect(result.contentType).toBe("text/plain");
// Verify path is in temp directory
expect(result.tempPath).toContain("telegram-temp");
expect(result.tempPath).toMatch(/telegram-dl-.*\.tmp$/);
} finally {
await result.cleanup();
}
});
it("throws on HTTP error (ok: false)", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
statusText: "Not Found",
} as any);
await expect(
streamDownloadToTemp("https://example.com/missing.txt", 1024),
).rejects.toThrow(/Failed to download media.*Not Found/);
});
it("throws when size exceeds maxSize", async () => {
// Create data larger than maxSize
const largeData = Buffer.alloc(1024 * 10); // 10KB
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
headers: {
get: () => null,
},
body: Readable.from([largeData]),
} as any);
await expect(
streamDownloadToTemp(
"https://example.com/large.bin",
1024, // Only allow 1KB
),
).rejects.toThrow(/Download size.*exceeds maximum/);
});
it("cleans up temp file on error", async () => {
const largeData = Buffer.alloc(1024 * 10);
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
headers: {
get: () => null,
},
body: Readable.from([largeData]),
} as any);
let _tempPath: string | undefined;
try {
await streamDownloadToTemp("https://example.com/large.bin", 1024);
} catch {
// Expected to fail
}
// Give cleanup a moment to complete
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify no temp files left
const tempDir = getTelegramTempDir();
const files = await fs.readdir(tempDir).catch(() => []);
const tempFiles = files.filter((f) => f.startsWith("telegram-dl-"));
expect(tempFiles.length).toBe(0);
});
it("cleanup() removes temp file", async () => {
const testData = Buffer.from("cleanup test");
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
headers: {
get: () => null,
},
body: Readable.from([testData]),
} as any);
const result = await streamDownloadToTemp(
"https://example.com/test.txt",
1024,
);
// Verify file exists
const existsBefore = await fs
.access(result.tempPath)
.then(() => true)
.catch(() => false);
expect(existsBefore).toBe(true);
// Cleanup
await result.cleanup();
// Verify file removed
const existsAfter = await fs
.access(result.tempPath)
.then(() => true)
.catch(() => false);
expect(existsAfter).toBe(false);
});
it("handles missing response body (body: null)", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
body: null,
} as any);
await expect(
streamDownloadToTemp("https://example.com/empty.txt", 1024),
).rejects.toThrow(/No response body/);
});
});
describe("cleanOrphanedTempFiles", () => {
it("removes old files (>TTL)", async () => {
const tempDir = getTelegramTempDir();
await ensureTempDir();
// Create a temp file
const oldFile = path.join(tempDir, "telegram-dl-old.tmp");
await fs.writeFile(oldFile, "old content");
// Mock stat to return old mtime
const statSpy = vi.spyOn(fs, "stat");
const oldDate = Date.now() - 2 * 60 * 60 * 1000; // 2 hours ago
statSpy.mockResolvedValue({
mtimeMs: oldDate,
} as any);
// Clean with 1 hour TTL
await cleanOrphanedTempFiles(60 * 60 * 1000);
// File should be removed
const exists = await fs
.access(oldFile)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
statSpy.mockRestore();
});
it("preserves recent files", async () => {
const tempDir = getTelegramTempDir();
await ensureTempDir();
// Create a recent file
const recentFile = path.join(tempDir, "telegram-dl-recent.tmp");
await fs.writeFile(recentFile, "recent content");
// Mock stat to return recent mtime
const statSpy = vi.spyOn(fs, "stat");
const recentDate = Date.now() - 10 * 60 * 1000; // 10 minutes ago
statSpy.mockResolvedValue({
mtimeMs: recentDate,
} as any);
// Clean with 1 hour TTL
await cleanOrphanedTempFiles(60 * 60 * 1000);
// File should still exist
const exists = await fs
.access(recentFile)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
// Clean up
await fs.rm(recentFile, { force: true });
statSpy.mockRestore();
});
it("handles missing temp directory", async () => {
const tempDir = getTelegramTempDir();
// Delete temp directory
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
// Should not throw
await expect(cleanOrphanedTempFiles()).resolves.not.toThrow();
});
it("continues on individual file errors", async () => {
const tempDir = getTelegramTempDir();
await ensureTempDir();
// Create multiple files
const file1 = path.join(tempDir, "telegram-dl-1.tmp");
const file2 = path.join(tempDir, "telegram-dl-2.tmp");
await fs.writeFile(file1, "content 1");
await fs.writeFile(file2, "content 2");
// Mock stat to return old dates
const statSpy = vi.spyOn(fs, "stat");
const oldDate = Date.now() - 2 * 60 * 60 * 1000;
statSpy.mockResolvedValue({
mtimeMs: oldDate,
} as any);
// Mock rm to fail on first file
const rmSpy = vi.spyOn(fs, "rm");
let callCount = 0;
rmSpy.mockImplementation((async (filePath: string, options?: any) => {
callCount++;
if (callCount === 1) {
throw new Error("Permission denied");
}
// Use actual implementation for other calls
return rmSpy.getMockImplementation
? Promise.resolve()
: fs.rm(filePath, options);
}) as any);
// Should not throw, continues processing
await expect(cleanOrphanedTempFiles()).resolves.not.toThrow();
// Clean up
await fs.rm(file1, { force: true }).catch(() => {});
await fs.rm(file2, { force: true }).catch(() => {});
statSpy.mockRestore();
rmSpy.mockRestore();
});
});
});

174
src/telegram/download.ts Normal file
View File

@ -0,0 +1,174 @@
import crypto from "node:crypto";
import { createWriteStream } from "node:fs";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
// Prefer ~/.clawdis/telegram-temp, but fall back to ~/.warelay for compatibility
const TEMP_DIR_CLAWDIS = path.join(os.homedir(), ".clawdis", "telegram-temp");
const TEMP_DIR_LEGACY = path.join(os.homedir(), ".warelay", "telegram-temp");
function resolveTempDir(): string {
// Use CLAWDIS path if the main config directory exists, otherwise legacy
const clawdisConfigExists = fsSync.existsSync(
path.join(os.homedir(), ".clawdis"),
);
return clawdisConfigExists ? TEMP_DIR_CLAWDIS : TEMP_DIR_LEGACY;
}
const TEMP_DIR = resolveTempDir();
const DEFAULT_ORPHAN_TTL_MS = 60 * 60 * 1000; // 1 hour
/**
* Result of a streaming download operation.
* Caller must clean up tempPath after use.
*/
export interface DownloadResult {
/** Absolute path to downloaded temp file */
tempPath: string;
/** Total bytes downloaded */
size: number;
/** Content-Type header from response (if available) */
contentType?: string;
/** Cleanup function - MUST be called to remove temp file */
cleanup: () => Promise<void>;
}
/**
* Get temp directory for Telegram downloads.
* Uses ~/.warelay/telegram-temp for consistency with media store.
*/
export function getTelegramTempDir(): string {
return TEMP_DIR;
}
/**
* Ensure temp directory exists.
*/
export async function ensureTempDir(): Promise<string> {
await fs.mkdir(TEMP_DIR, { recursive: true });
return TEMP_DIR;
}
/**
* Download URL to temporary file using Node.js streams.
* Eliminates memory buffering for large files.
*
* IMPORTANT: Caller MUST call result.cleanup() after use.
* Use try-finally pattern to ensure cleanup on errors.
*
* @param url - URL to download
* @param maxSize - Maximum size in bytes (throws if exceeded)
* @returns DownloadResult with tempPath and cleanup function
* @throws Error if download fails, size exceeds maxSize, or disk write fails
*
* @example
* const download = await streamDownloadToTemp(url, maxSize);
* try {
* await client.sendFile(entity, { file: download.tempPath });
* } finally {
* await download.cleanup();
* }
*/
export async function streamDownloadToTemp(
url: string,
maxSize: number,
): Promise<DownloadResult> {
await ensureTempDir();
// Generate unique temp file name
const filename = `telegram-dl-${crypto.randomUUID()}.tmp`;
const tempPath = path.join(TEMP_DIR, filename);
// Fetch response
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download media from ${url}: ${response.statusText}`,
);
}
if (!response.body) {
throw new Error(`No response body for ${url}`);
}
// Track size during download
let totalSize = 0;
const writeStream = createWriteStream(tempPath);
try {
// Use pipeline for automatic backpressure handling
// Transform stream to track size and enforce limit
const trackingStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
totalSize += chunk.length;
if (totalSize > maxSize) {
callback(
new Error(
`Download size ${totalSize} exceeds maximum ${maxSize} bytes`,
),
);
return;
}
callback(null, chunk);
},
});
// Pipeline: fetch response -> size tracker -> file write
await pipeline(response.body, trackingStream, writeStream);
const contentType = response.headers.get("content-type") || undefined;
return {
tempPath,
size: totalSize,
contentType,
cleanup: async () => {
await fs.rm(tempPath, { force: true }).catch(() => {
// Suppress cleanup errors (file may already be deleted)
});
},
};
} catch (error) {
// Clean up temp file on failure
await fs.rm(tempPath, { force: true }).catch(() => {});
throw error;
}
}
/**
* Clean up orphaned temp files older than TTL.
* Run on process start to handle crash recovery.
*
* @param ttlMs - Time-to-live in milliseconds (default: 1 hour)
*/
export async function cleanOrphanedTempFiles(
ttlMs = DEFAULT_ORPHAN_TTL_MS,
): Promise<void> {
try {
await ensureTempDir();
const entries = await fs.readdir(TEMP_DIR).catch(() => []);
const now = Date.now();
await Promise.all(
entries.map(async (file) => {
const fullPath = path.join(TEMP_DIR, file);
const stat = await fs.stat(fullPath).catch(() => null);
if (!stat) return;
if (now - stat.mtimeMs > ttlMs) {
await fs.rm(fullPath, { force: true }).catch(() => {});
}
}),
);
} catch (error) {
// Non-fatal: log but don't throw
console.warn("Failed to clean orphaned temp files:", error);
}
}

View File

@ -0,0 +1,876 @@
/**
* Telegram Inbound Message Tests
*/
import type { TelegramClient } from "telegram";
import type { NewMessageEvent } from "telegram/events";
import { Api } from "telegram/tl";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
MessageHandler,
ProviderMessage,
} from "../providers/base/types.js";
import {
convertTelegramMessage,
isAllowedSender,
startMessageListener,
} from "./inbound.js";
// Mock telegram events
vi.mock("telegram/events", () => ({
NewMessage: class NewMessage {},
}));
describe("convertTelegramMessage", () => {
let mockClient: Partial<TelegramClient>;
let mockEvent: Partial<NewMessageEvent>;
beforeEach(() => {
mockClient = {
downloadMedia: vi.fn(),
};
});
it("converts text message successfully", async () => {
const mockSender = {
username: "testuser",
firstName: "Test",
lastName: "User",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello, world!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result).toEqual({
id: "999",
from: "@testuser",
to: "me",
body: "Hello, world!",
timestamp: 1234567890000,
displayName: "Test User",
media: undefined,
raw: mockMessage,
provider: "telegram",
});
});
it("returns null for outgoing messages", async () => {
const mockMessage = {
id: 999,
message: "Hello!",
out: true,
};
mockEvent = {
message: mockMessage as any,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result).toBeNull();
});
it("returns null when sender is not available", async () => {
const mockMessage = {
id: 999,
message: "Hello!",
out: false,
getSender: vi.fn().mockResolvedValue(null),
};
mockEvent = {
message: mockMessage as any,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result).toBeNull();
});
it("uses phone number when username not available", async () => {
const mockSender = {
phone: "+1234567890",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.from).toBe("+1234567890");
});
it("uses ID when neither username nor phone available", async () => {
const mockSender = {
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.from).toBe("12345");
});
it("uses Unknown when no identifiable info available", async () => {
const mockSender = {};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.from).toBe("unknown");
expect(result?.displayName).toBe("Unknown");
});
it("uses title for chat display name", async () => {
const mockSender = {
title: "Test Group",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.displayName).toBe("Test Group");
});
it("extracts photo media", async () => {
const mockBuffer = Buffer.from("fake-image-data");
mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer);
const mockPhoto = new Api.MessageMediaPhoto({
photo: new Api.Photo({
id: BigInt(123),
accessHash: BigInt(456),
fileReference: Buffer.from([]),
date: 1234567890,
sizes: [],
dcId: 1,
}),
});
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Check this out",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: mockPhoto,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.media).toHaveLength(1);
expect(result?.media?.[0]).toEqual({
type: "image",
buffer: mockBuffer,
mimeType: "image/jpeg",
});
});
it("extracts document media with voice attribute", async () => {
const mockBuffer = Buffer.from("fake-audio-data");
mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer);
// Create mock document with voice attribute
const mockDocument = {
className: "MessageMediaDocument",
document: {
id: BigInt(123),
mimeType: "audio/ogg",
size: BigInt(1024),
attributes: [
{
className: "DocumentAttributeVoice",
},
],
},
} as any;
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: mockDocument,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.media).toHaveLength(1);
expect(result?.media?.[0]?.type).toBe("voice");
expect(result?.media?.[0]?.mimeType).toBe("audio/ogg");
});
it("extracts document media with video attribute", async () => {
const mockBuffer = Buffer.from("fake-video-data");
mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer);
const mockDocument = {
className: "MessageMediaDocument",
document: {
id: BigInt(123),
mimeType: "video/mp4",
size: BigInt(2048),
attributes: [
{
className: "DocumentAttributeVideo",
duration: 10,
w: 1920,
h: 1080,
},
],
},
} as any;
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: mockDocument,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.media).toHaveLength(1);
expect(result?.media?.[0]?.type).toBe("video");
expect(result?.media?.[0]?.mimeType).toBe("video/mp4");
});
it("extracts document media with audio attribute", async () => {
const mockBuffer = Buffer.from("fake-audio-data");
mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer);
const mockDocument = {
className: "MessageMediaDocument",
document: {
id: BigInt(123),
mimeType: "audio/mp3",
size: BigInt(1024),
attributes: [
{
className: "DocumentAttributeAudio",
duration: 180,
},
],
},
} as any;
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: mockDocument,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.media).toHaveLength(1);
expect(result?.media?.[0]?.type).toBe("audio");
expect(result?.media?.[0]?.mimeType).toBe("audio/mp3");
});
it("extracts document media with filename", async () => {
const mockBuffer = Buffer.from("fake-doc-data");
mockClient.downloadMedia = vi.fn().mockResolvedValue(mockBuffer);
const mockDocument = {
className: "MessageMediaDocument",
document: {
id: BigInt(123),
mimeType: "application/pdf",
size: BigInt(4096),
attributes: [
{
className: "DocumentAttributeFilename",
fileName: "test.pdf",
},
],
},
} as any;
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Here's a document",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: mockDocument,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.media).toHaveLength(1);
expect(result?.media?.[0]?.type).toBe("document");
expect(result?.media?.[0]?.fileName).toBe("test.pdf");
expect(result?.media?.[0]?.size).toBe(4096);
});
it("handles media download failure gracefully", async () => {
mockClient.downloadMedia = vi
.fn()
.mockRejectedValue(new Error("Download failed"));
const mockPhoto = new Api.MessageMediaPhoto({
photo: new Api.Photo({
id: BigInt(123),
accessHash: BigInt(456),
fileReference: Buffer.from([]),
date: 1234567890,
sizes: [],
dcId: 1,
}),
});
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Check this out",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: mockPhoto,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
// Should not throw, just omit media
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
expect(result?.media).toBeUndefined();
});
it("uses current timestamp when message date is missing", async () => {
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 0,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
mockEvent = {
message: mockMessage as any,
client: mockClient as TelegramClient,
};
const beforeTime = Date.now();
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
const afterTime = Date.now();
expect(result?.timestamp).toBeGreaterThanOrEqual(beforeTime);
expect(result?.timestamp).toBeLessThanOrEqual(afterTime);
});
});
describe("isAllowedSender", () => {
it("allows all senders when no whitelist provided", () => {
const message: ProviderMessage = {
id: "1",
from: "@testuser",
to: "me",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
expect(isAllowedSender(message, undefined)).toBe(true);
expect(isAllowedSender(message, [])).toBe(true);
});
it("allows sender with matching username", () => {
const message: ProviderMessage = {
id: "1",
from: "@testuser",
to: "me",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
expect(isAllowedSender(message, ["@testuser"])).toBe(true);
expect(isAllowedSender(message, ["testuser"])).toBe(true); // Without @
});
it("allows sender with matching phone", () => {
const message: ProviderMessage = {
id: "1",
from: "+1234567890",
to: "me",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
expect(isAllowedSender(message, ["+1234567890"])).toBe(true);
});
it("rejects sender not in whitelist", () => {
const message: ProviderMessage = {
id: "1",
from: "@unauthorized",
to: "me",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
expect(isAllowedSender(message, ["@testuser", "+1234567890"])).toBe(false);
});
it("is case insensitive", () => {
const message: ProviderMessage = {
id: "1",
from: "@TestUser",
to: "me",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
expect(isAllowedSender(message, ["@testuser"])).toBe(true);
expect(isAllowedSender(message, ["TESTUSER"])).toBe(true);
});
it("trims whitespace from whitelist entries", () => {
const message: ProviderMessage = {
id: "1",
from: "@testuser",
to: "me",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
expect(isAllowedSender(message, [" @testuser "])).toBe(true);
});
it("allows sender in multi-entry whitelist", () => {
const message: ProviderMessage = {
id: "1",
from: "@user2",
to: "me",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
expect(isAllowedSender(message, ["@user1", "@user2", "+1234567890"])).toBe(
true,
);
});
});
describe("startMessageListener", () => {
let mockClient: Partial<TelegramClient>;
let mockHandler: MessageHandler;
beforeEach(() => {
mockClient = {
addEventHandler: vi.fn(),
removeEventHandler: vi.fn(),
downloadMedia: vi.fn(),
};
mockHandler = vi.fn().mockResolvedValue(undefined);
});
it("registers event handler on client", async () => {
await startMessageListener(mockClient as TelegramClient, mockHandler);
expect(mockClient.addEventHandler).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Object),
);
});
it("returns cleanup function that removes event handler", async () => {
const cleanup = await startMessageListener(
mockClient as TelegramClient,
mockHandler,
);
expect(typeof cleanup).toBe("function");
cleanup();
expect(mockClient.removeEventHandler).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Object),
);
});
it("calls handler for valid incoming messages", async () => {
let capturedHandler: ((event: NewMessageEvent) => Promise<void>) | null =
null;
mockClient.addEventHandler = vi.fn().mockImplementation((handler) => {
capturedHandler = handler;
});
await startMessageListener(mockClient as TelegramClient, mockHandler);
expect(capturedHandler).not.toBeNull();
// Simulate incoming message
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
const mockEvent = {
message: mockMessage,
client: mockClient,
} as any;
await capturedHandler?.(mockEvent);
expect(mockHandler).toHaveBeenCalledWith(
expect.objectContaining({
id: "999",
from: "@testuser",
body: "Hello!",
}),
);
});
it("does not call handler for outgoing messages", async () => {
let capturedHandler: ((event: NewMessageEvent) => Promise<void>) | null =
null;
mockClient.addEventHandler = vi.fn().mockImplementation((handler) => {
capturedHandler = handler;
});
await startMessageListener(mockClient as TelegramClient, mockHandler);
// Simulate outgoing message
const mockMessage = {
id: 999,
message: "Hello!",
out: true,
};
const mockEvent = {
message: mockMessage,
client: mockClient,
} as any;
await capturedHandler?.(mockEvent);
expect(mockHandler).not.toHaveBeenCalled();
});
it("filters messages by allowFrom whitelist", async () => {
let capturedHandler: ((event: NewMessageEvent) => Promise<void>) | null =
null;
mockClient.addEventHandler = vi.fn().mockImplementation((handler) => {
capturedHandler = handler;
});
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await startMessageListener(mockClient as TelegramClient, mockHandler, [
"@allowed",
]);
// Simulate message from non-whitelisted user
const mockSender = {
username: "unauthorized",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
const mockEvent = {
message: mockMessage,
client: mockClient,
} as any;
await capturedHandler?.(mockEvent);
expect(mockHandler).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith(
"Ignored message from @unauthorized (not in allowFrom list)",
);
consoleLogSpy.mockRestore();
});
it("allows messages from whitelisted users", async () => {
let capturedHandler: ((event: NewMessageEvent) => Promise<void>) | null =
null;
mockClient.addEventHandler = vi.fn().mockImplementation((handler) => {
capturedHandler = handler;
});
await startMessageListener(mockClient as TelegramClient, mockHandler, [
"@testuser",
]);
// Simulate message from whitelisted user
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
const mockEvent = {
message: mockMessage,
client: mockClient,
} as any;
await capturedHandler?.(mockEvent);
expect(mockHandler).toHaveBeenCalled();
});
it("handles errors in message handler gracefully", async () => {
let capturedHandler: ((event: NewMessageEvent) => Promise<void>) | null =
null;
mockClient.addEventHandler = vi.fn().mockImplementation((handler) => {
capturedHandler = handler;
});
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const errorHandler = vi.fn().mockRejectedValue(new Error("Handler failed"));
await startMessageListener(mockClient as TelegramClient, errorHandler);
// Simulate incoming message
const mockSender = {
username: "testuser",
firstName: "Test",
id: BigInt(12345),
};
const mockMessage = {
id: 999,
message: "Hello!",
date: 1234567890,
out: false,
getSender: vi.fn().mockResolvedValue(mockSender),
media: null,
};
const mockEvent = {
message: mockMessage,
client: mockClient,
} as any;
// Should not throw
await expect(capturedHandler?.(mockEvent)).resolves.not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error handling Telegram message"),
);
consoleErrorSpy.mockRestore();
});
it("uses same NewMessage instance for add and remove", async () => {
let addedFilter: any = null;
let removedFilter: any = null;
mockClient.addEventHandler = vi
.fn()
.mockImplementation((_handler, filter) => {
addedFilter = filter;
});
mockClient.removeEventHandler = vi
.fn()
.mockImplementation((_handler, filter) => {
removedFilter = filter;
});
const cleanup = await startMessageListener(
mockClient as TelegramClient,
mockHandler,
);
cleanup();
// Verify same instance used for both add and remove (strict equality)
expect(addedFilter).toBe(removedFilter);
expect(addedFilter).not.toBeNull();
});
it("cleanup can be called multiple times safely", async () => {
const cleanup = await startMessageListener(
mockClient as TelegramClient,
mockHandler,
);
cleanup();
cleanup(); // Should not throw
expect(mockClient.removeEventHandler).toHaveBeenCalled();
});
});

250
src/telegram/inbound.ts Normal file
View File

@ -0,0 +1,250 @@
import type { TelegramClient } from "telegram";
import { Api } from "telegram";
import type { NewMessageEvent } from "telegram/events/NewMessage.js";
import { NewMessage } from "telegram/events/NewMessage.js";
import type {
MessageHandler,
ProviderMedia,
ProviderMessage,
} from "../providers/base/types.js";
import { normalizeAllowFromEntry } from "../utils.js";
/**
* Convert Telegram message to ProviderMessage format.
*/
export async function convertTelegramMessage(
event: NewMessageEvent,
): Promise<ProviderMessage | null> {
const msg = event.message;
// Only process incoming messages (not outgoing)
if (msg.out) {
return null;
}
// Extract sender info
const sender = await event.message.getSender();
if (!sender) {
return null;
}
const from = extractSenderIdentifier(sender as any);
const displayName = extractDisplayName(sender as any);
// Extract message body
const body = msg.message || "";
// Extract media if present
const media: ProviderMedia[] = [];
if (msg.media && event.client) {
const mediaItem = await extractMedia(msg.media, event.client);
if (mediaItem) {
media.push(mediaItem);
}
}
return {
id: msg.id.toString(),
from,
to: "me", // Always "me" for personal account
body,
timestamp: msg.date ? msg.date * 1000 : Date.now(),
displayName,
media: media.length > 0 ? media : undefined,
raw: msg,
provider: "telegram",
};
}
/**
* Extract sender identifier (@username or phone).
*/
function extractSenderIdentifier(sender: Api.User | Api.Chat): string {
if ("username" in sender && sender.username) {
return `@${sender.username}`;
}
if ("phone" in sender && sender.phone) {
return sender.phone;
}
if ("id" in sender && sender.id) {
return sender.id.toString();
}
return "unknown";
}
/**
* Extract display name from sender.
*/
function extractDisplayName(sender: Api.User | Api.Chat): string {
if ("firstName" in sender && sender.firstName) {
const lastName =
"lastName" in sender && sender.lastName ? ` ${sender.lastName}` : "";
return `${sender.firstName}${lastName}`;
}
if ("title" in sender && sender.title) {
return sender.title;
}
return "Unknown";
}
/**
* Extract media from Telegram message.
*/
async function extractMedia(
media: Api.TypeMessageMedia,
client: TelegramClient,
): Promise<ProviderMedia | null> {
try {
// Check for photo media (by instanceof or className for test compatibility)
const isPhoto =
media instanceof Api.MessageMediaPhoto ||
(media as any).className === "MessageMediaPhoto";
if (isPhoto && (media as any).photo) {
const buffer = await client.downloadMedia(media, {
outputFile: undefined,
});
if (buffer instanceof Buffer) {
return {
type: "image",
buffer,
mimeType: "image/jpeg",
};
}
}
// Check for document media (by instanceof or className for test compatibility)
const isDocument =
media instanceof Api.MessageMediaDocument ||
(media as any).className === "MessageMediaDocument";
if (isDocument && (media as any).document) {
const doc = (media as any).document as Api.Document;
const buffer = await client.downloadMedia(media, {
outputFile: undefined,
});
if (buffer instanceof Buffer) {
// Detect media type from attributes
const attrs = doc.attributes || [];
// Helper to check attribute type safely (handles test env where Api classes may not exist)
const isAttrType = (a: any, className: string) => {
try {
switch (className) {
case "DocumentAttributeVideo":
return (
a instanceof Api.DocumentAttributeVideo ||
a.className === className
);
case "DocumentAttributeAudio":
return (
a instanceof Api.DocumentAttributeAudio ||
a.className === className
);
case "DocumentAttributeFilename":
return (
a instanceof Api.DocumentAttributeFilename ||
a.className === className
);
default:
return a.className === className;
}
} catch {
// If instanceof fails (test env), fallback to className
return a.className === className;
}
};
const isVideo = attrs.some((a: any) =>
isAttrType(a, "DocumentAttributeVideo"),
);
const isAudio = attrs.some((a: any) =>
isAttrType(a, "DocumentAttributeAudio"),
);
// Check if it's a voice message by mime type (e.g., audio/ogg with opus codec)
const isVoice =
doc.mimeType === "audio/ogg" || doc.mimeType === "audio/opus";
let type: ProviderMedia["type"] = "document";
if (isVoice) type = "voice";
else if (isVideo) type = "video";
else if (isAudio) type = "audio";
else if (doc.mimeType?.startsWith("image/")) type = "image";
const fileName = attrs
.filter((a: any) => isAttrType(a, "DocumentAttributeFilename"))
.map((a: any) => a.fileName)[0];
return {
type,
buffer,
mimeType: doc.mimeType || "application/octet-stream",
fileName,
size: Number(doc.size),
};
}
}
} catch (err) {
console.warn(`Failed to download media: ${String(err)}`);
}
return null;
}
/**
* Check if message sender is in allowFrom whitelist.
*/
export function isAllowedSender(
message: ProviderMessage,
allowFrom?: string[],
): boolean {
if (!allowFrom || allowFrom.length === 0) {
return true; // No whitelist = allow all
}
const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram");
const normalizedAllowList = allowFrom.map((e) =>
normalizeAllowFromEntry(e, "telegram"),
);
return normalizedAllowList.includes(normalizedFrom);
}
/**
* Start listening for inbound messages with allowFrom filtering.
*/
export async function startMessageListener(
client: TelegramClient,
handler: MessageHandler,
allowFrom?: string[],
): Promise<() => void> {
const eventHandler = async (event: NewMessageEvent) => {
try {
const message = await convertTelegramMessage(event);
if (!message) {
return; // Outgoing or invalid message
}
// Check allowFrom whitelist
if (!isAllowedSender(message, allowFrom)) {
console.log(
`Ignored message from ${message.from} (not in allowFrom list)`,
);
return;
}
// Call handler
await handler(message);
} catch (err) {
console.error(`Error handling Telegram message: ${String(err)}`);
}
};
// Create event filter instance and reuse for both add and remove
const eventFilter = new NewMessage({});
client.addEventHandler(eventHandler, eventFilter);
// Return cleanup function
return () => {
client.removeEventHandler(eventHandler, eventFilter);
};
}

16
src/telegram/index.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* Telegram Provider Module
*
* Exports all Telegram-related functionality for warelay.
*/
export * from "./capabilities.js";
export * from "./client.js";
export * from "./inbound.js";
export * from "./login.js";
export * from "./monitor.js";
export * from "./outbound.js";
export * from "./prompts.js";
export * from "./provider.js";
export * from "./session.js";
export * from "./utils.js";

306
src/telegram/login.test.ts Normal file
View File

@ -0,0 +1,306 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
// Mock all dependencies before imports
const mockStart = vi.fn();
const mockGetMe = vi.fn();
const mockDisconnect = vi.fn();
const mockConnect = vi.fn();
const mockInvoke = vi.fn();
vi.mock("telegram", () => ({
Api: {
auth: {
LogOut: class {},
},
},
}));
vi.mock("./client.js", () => ({
createTelegramClient: vi.fn().mockResolvedValue({
start: mockStart,
getMe: mockGetMe,
disconnect: mockDisconnect,
connect: mockConnect,
invoke: mockInvoke,
connected: true,
session: {
save: vi.fn(() => "mock-saved-session"),
},
}),
}));
vi.mock("./session.js", () => ({
loadSession: vi.fn(),
saveSession: vi.fn(),
clearSession: vi.fn(),
}));
vi.mock("./prompts.js", () => ({
promptPhone: vi.fn(),
promptSMSCode: vi.fn(),
prompt2FA: vi.fn(),
}));
const { loginTelegram, logoutTelegram } = await import("./login.js");
const { loadSession, saveSession, clearSession } = await import("./session.js");
const { promptPhone, promptSMSCode, prompt2FA } = await import("./prompts.js");
const { createTelegramClient } = await import("./client.js");
describe("telegram login", () => {
const mockRuntime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
beforeEach(() => {
vi.clearAllMocks();
mockStart.mockClear();
mockGetMe.mockClear();
mockDisconnect.mockClear();
mockConnect.mockClear();
mockInvoke.mockClear();
});
describe("loginTelegram", () => {
it("successfully logs in with phone and SMS code", async () => {
vi.mocked(loadSession).mockResolvedValue(null);
vi.mocked(promptPhone).mockResolvedValue("+1234567890");
vi.mocked(promptSMSCode).mockResolvedValue("12345");
vi.mocked(prompt2FA).mockResolvedValue("");
mockStart.mockImplementation(async (opts: unknown) => {
// Simulate successful login
const { phoneNumber, phoneCode, password } = opts as {
phoneNumber: () => Promise<string>;
phoneCode: () => Promise<string>;
password: () => Promise<string>;
};
await phoneNumber();
await phoneCode();
await password();
});
mockGetMe.mockResolvedValue({
firstName: "John",
username: "johndoe",
});
await loginTelegram(false, mockRuntime);
expect(createTelegramClient).toHaveBeenCalledWith(
null,
false,
mockRuntime,
);
expect(mockStart).toHaveBeenCalled();
expect(mockGetMe).toHaveBeenCalled();
expect(saveSession).toHaveBeenCalled();
expect(mockRuntime.log).toHaveBeenCalledWith(
expect.stringContaining("Logged in as: John (@johndoe)"),
);
expect(mockDisconnect).toHaveBeenCalled();
});
it("logs in with 2FA password", async () => {
vi.mocked(loadSession).mockResolvedValue(null);
vi.mocked(promptPhone).mockResolvedValue("+1234567890");
vi.mocked(promptSMSCode).mockResolvedValue("12345");
vi.mocked(prompt2FA).mockResolvedValue("my2fapassword");
mockStart.mockImplementation(async (opts: unknown) => {
const { phoneNumber, phoneCode, password } = opts as {
phoneNumber: () => Promise<string>;
phoneCode: () => Promise<string>;
password: () => Promise<string>;
};
await phoneNumber();
await phoneCode();
const pwd = await password();
expect(pwd).toBe("my2fapassword");
});
mockGetMe.mockResolvedValue({
firstName: "Jane",
username: null,
});
await loginTelegram(false, mockRuntime);
expect(mockStart).toHaveBeenCalled();
expect(mockGetMe).toHaveBeenCalled();
expect(saveSession).toHaveBeenCalled();
expect(mockRuntime.log).toHaveBeenCalledWith(
expect.stringContaining("Logged in as: Jane"),
);
expect(mockDisconnect).toHaveBeenCalled();
});
it("handles login failure and exits", async () => {
vi.mocked(loadSession).mockResolvedValue(null);
mockStart.mockRejectedValue(new Error("Invalid phone number"));
try {
await loginTelegram(false, mockRuntime);
throw new Error("Should have thrown");
} catch (err) {
expect((err as Error).message).toBe("exit");
}
expect(mockRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("Login failed: Error: Invalid phone number"),
);
expect(mockRuntime.exit).toHaveBeenCalledWith(1);
expect(saveSession).not.toHaveBeenCalled();
expect(mockDisconnect).toHaveBeenCalled();
});
it("handles connection failure", async () => {
vi.mocked(loadSession).mockResolvedValue(null);
mockStart.mockResolvedValue(undefined);
// Mock client with connected = false
vi.mocked(createTelegramClient).mockResolvedValue({
start: mockStart,
getMe: mockGetMe,
disconnect: mockDisconnect,
connect: mockConnect,
invoke: mockInvoke,
connected: false,
session: {
save: vi.fn(() => "mock-saved-session"),
},
} as never);
try {
await loginTelegram(false, mockRuntime);
throw new Error("Should have thrown");
} catch (err) {
expect((err as Error).message).toBe("exit");
}
expect(mockRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to connect to Telegram"),
);
expect(mockRuntime.exit).toHaveBeenCalledWith(1);
expect(saveSession).not.toHaveBeenCalled();
});
it("uses existing session when available", async () => {
const mockSession = {
save: vi.fn(() => "existing-session"),
};
vi.mocked(loadSession).mockResolvedValue(mockSession as never);
// Reset mock to return connected client
vi.mocked(createTelegramClient).mockResolvedValue({
start: mockStart,
getMe: mockGetMe,
disconnect: mockDisconnect,
connect: mockConnect,
invoke: mockInvoke,
connected: true,
session: {
save: vi.fn(() => "mock-saved-session"),
},
} as never);
mockStart.mockImplementation(async (opts: unknown) => {
// Simulate successful login
const { phoneNumber, phoneCode, password } = opts as {
phoneNumber: () => Promise<string>;
phoneCode: () => Promise<string>;
password: () => Promise<string>;
};
await phoneNumber();
await phoneCode();
await password();
});
mockGetMe.mockResolvedValue({
firstName: "Alice",
username: "alice",
});
await loginTelegram(false, mockRuntime);
expect(createTelegramClient).toHaveBeenCalledWith(
mockSession,
false,
mockRuntime,
);
expect(mockStart).toHaveBeenCalled();
});
});
describe("logoutTelegram", () => {
beforeEach(() => {
// Reset the mock to return default client
vi.mocked(createTelegramClient).mockResolvedValue({
start: mockStart,
getMe: mockGetMe,
disconnect: mockDisconnect,
connect: mockConnect,
invoke: mockInvoke,
connected: true,
session: {
save: vi.fn(() => "mock-saved-session"),
},
} as never);
});
it("successfully logs out and clears session", async () => {
const mockSession = {
save: vi.fn(() => "existing-session"),
};
vi.mocked(loadSession).mockResolvedValue(mockSession as never);
await logoutTelegram(false, mockRuntime);
expect(mockConnect).toHaveBeenCalled();
expect(mockInvoke).toHaveBeenCalled();
expect(clearSession).toHaveBeenCalled();
expect(mockRuntime.log).toHaveBeenCalledWith(
expect.stringContaining("Logged out from Telegram"),
);
expect(mockDisconnect).toHaveBeenCalled();
});
it("does nothing when no session exists", async () => {
vi.mocked(loadSession).mockResolvedValue(null);
await logoutTelegram(false, mockRuntime);
expect(mockRuntime.log).toHaveBeenCalledWith(
expect.stringContaining("No Telegram session found"),
);
expect(mockConnect).not.toHaveBeenCalled();
expect(mockInvoke).not.toHaveBeenCalled();
expect(clearSession).not.toHaveBeenCalled();
});
it("handles logout failure and exits", async () => {
const mockSession = {
save: vi.fn(() => "existing-session"),
};
vi.mocked(loadSession).mockResolvedValue(mockSession as never);
mockInvoke.mockRejectedValue(new Error("Network error"));
try {
await logoutTelegram(false, mockRuntime);
throw new Error("Should have thrown");
} catch (err) {
expect((err as Error).message).toBe("exit");
}
expect(mockRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("Logout failed: Error: Network error"),
);
expect(mockRuntime.exit).toHaveBeenCalledWith(1);
expect(clearSession).not.toHaveBeenCalled();
expect(mockDisconnect).toHaveBeenCalled();
});
});
});

94
src/telegram/login.ts Normal file
View File

@ -0,0 +1,94 @@
import { Api } from "telegram";
import type { StringSession } from "telegram/sessions/index.js";
import { danger, info, success } from "../globals.js";
import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createTelegramClient } from "./client.js";
import { prompt2FA, promptPhone, promptSMSCode } from "./prompts.js";
import { clearSession, loadSession, saveSession } from "./session.js";
/**
* Interactive login flow for Telegram.
* Prompts for phone, SMS code, and optionally 2FA password.
*/
export async function loginTelegram(
verbose: boolean,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
runtime.log(info("🔐 Telegram Login"));
runtime.log(
info("This will connect your personal Telegram account to warelay."),
);
const session = await loadSession();
const client = await createTelegramClient(session, verbose, runtime);
try {
await client.start({
phoneNumber: async () => await promptPhone(runtime),
phoneCode: async () => await promptSMSCode(runtime),
password: async () => await prompt2FA(runtime),
onError: (err) => {
runtime.error(danger(`Login error: ${String(err)}`));
},
});
if (!client.connected) {
throw new Error("Failed to connect to Telegram");
}
// Get user info for confirmation
const me = await client.getMe();
const username =
"username" in me && typeof me.username === "string" ? me.username : null;
const displayName =
"firstName" in me && typeof me.firstName === "string"
? me.firstName
: "Unknown";
// Save session to disk
await saveSession(client.session as StringSession);
runtime.log(
success(
`✅ Logged in as: ${displayName}${username ? ` (@${username})` : ""}`,
),
);
runtime.log(info("Session saved to ~/.clawdis/telegram/session/"));
logInfo("Telegram login successful", runtime);
} catch (err) {
runtime.error(danger(`Login failed: ${String(err)}`));
runtime.exit(1);
} finally {
await client.disconnect();
}
}
/**
* Logout from Telegram and clear session.
*/
export async function logoutTelegram(
verbose: boolean,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
const session = await loadSession();
if (!session) {
runtime.log(info("No Telegram session found."));
return;
}
const client = await createTelegramClient(session, verbose, runtime);
try {
await client.connect();
await client.invoke(new Api.auth.LogOut());
await clearSession();
runtime.log(success("✅ Logged out from Telegram"));
} catch (err) {
runtime.error(danger(`Logout failed: ${String(err)}`));
runtime.exit(1);
} finally {
await client.disconnect();
}
}

View File

@ -0,0 +1,338 @@
/**
* Tests for Telegram monitor
*/
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
import type { Provider } from "../providers/base/interface.js";
import type {
MessageHandler,
ProviderMessage,
} from "../providers/base/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { monitorTelegramProvider } from "./monitor.js";
// Mock dependencies
vi.mock("../env.js", () => ({
readEnv: vi.fn(() => ({
accountSid: "test-sid",
whatsappFrom: "+1234567890",
auth: { accountSid: "test-sid", authToken: "test-token" },
telegram: {
apiId: 12345,
apiHash: "test-hash",
},
})),
}));
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(() => ({
telegram: {
allowFrom: ["@testuser"],
},
inbound: {
reply: {
mode: "text",
text: "Auto-reply test",
},
},
})),
}));
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: vi.fn(async () => ({
text: "Test reply",
})),
}));
vi.mock("../providers/factory.js", () => ({
createInitializedProvider: vi.fn(),
}));
describe("monitorTelegramProvider", () => {
let mockRuntime: RuntimeEnv;
let mockProvider: Provider;
let messageHandler: MessageHandler | null = null;
beforeEach(async () => {
vi.clearAllMocks();
messageHandler = null;
mockRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
env: {},
};
mockProvider = {
kind: "telegram",
capabilities: {
supportsDeliveryReceipts: false,
supportsReadReceipts: false,
supportsTypingIndicator: true,
supportsReactions: false,
supportsReplies: true,
supportsEditing: true,
supportsDeleting: true,
maxMediaSize: 50 * 1024 * 1024,
supportedMediaTypes: ["image", "video", "audio", "document"],
canInitiateConversation: true,
},
initialize: vi.fn(),
isConnected: vi.fn(() => true),
disconnect: vi.fn(),
send: vi.fn(async () => ({
messageId: "msg-123",
status: "sent" as const,
})),
sendTyping: vi.fn(),
getDeliveryStatus: vi.fn(async () => ({
messageId: "msg-123",
status: "unknown" as const,
timestamp: Date.now(),
})),
onMessage: vi.fn((handler: MessageHandler) => {
messageHandler = handler;
}),
startListening: vi.fn(),
stopListening: vi.fn(),
isAuthenticated: vi.fn(async () => true),
login: vi.fn(),
logout: vi.fn(),
getSessionId: vi.fn(async () => "@testuser"),
};
const { createInitializedProvider } = await import(
"../providers/factory.js"
);
(createInitializedProvider as Mock).mockResolvedValue(mockProvider);
});
it("should throw if Telegram is not configured", async () => {
const { readEnv } = await import("../env.js");
(readEnv as Mock).mockReturnValueOnce({
accountSid: "test-sid",
whatsappFrom: "+1234567890",
auth: { accountSid: "test-sid", authToken: "test-token" },
// No telegram config
});
await expect(monitorTelegramProvider(false, mockRuntime)).rejects.toThrow(
"Telegram not configured",
);
});
it("should create and initialize provider with correct config", async () => {
const { createInitializedProvider } = await import(
"../providers/factory.js"
);
// Mock startListening to return immediately for test
(mockProvider.startListening as Mock).mockImplementationOnce(async () => {
// Immediately resolve to avoid hanging
});
// Start monitor in background
const _monitorPromise = monitorTelegramProvider(true, mockRuntime);
// Wait a bit for initialization
await new Promise((resolve) => setTimeout(resolve, 10));
expect(createInitializedProvider).toHaveBeenCalledWith("telegram", {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
sessionDir: undefined,
allowFrom: ["@testuser"],
verbose: true,
});
expect(mockProvider.onMessage).toHaveBeenCalled();
expect(mockProvider.startListening).toHaveBeenCalled();
// monitorPromise will hang indefinitely, but we've verified the setup
});
it("should register message handler and handle inbound messages", async () => {
const { getReplyFromConfig } = await import("../auto-reply/reply.js");
// Mock startListening to call handler with test message
(mockProvider.startListening as Mock).mockImplementationOnce(async () => {
// Simulate receiving a message
if (messageHandler) {
const testMessage: ProviderMessage = {
id: "test-msg-123",
from: "@testuser",
to: "@botuser",
body: "Hello bot",
timestamp: Date.now(),
provider: "telegram",
};
await messageHandler(testMessage);
}
});
// Start monitor
const _monitorPromise = monitorTelegramProvider(false, mockRuntime);
// Wait for message to be processed
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify reply was requested
expect(getReplyFromConfig).toHaveBeenCalledWith(
expect.objectContaining({
Body: "Hello bot",
From: "@testuser",
To: "@botuser",
}),
expect.objectContaining({
onReplyStart: expect.any(Function),
}),
expect.anything(),
);
// Verify reply was sent
expect(mockProvider.send).toHaveBeenCalledWith(
"@testuser",
"Test reply",
expect.anything(),
);
// monitorPromise will hang, but we've verified message handling
});
it("should filter messages based on allowFrom config", async () => {
const { loadConfig } = await import("../config/config.js");
const { getReplyFromConfig } = await import("../auto-reply/reply.js");
// Set allowFrom filter
(loadConfig as Mock).mockReturnValueOnce({
telegram: {
allowFrom: ["@alloweduser"],
},
inbound: {
reply: {
mode: "text",
text: "Auto-reply test",
},
},
});
// Mock startListening to call handler with message from non-allowed user
(mockProvider.startListening as Mock).mockImplementationOnce(async () => {
if (messageHandler) {
const testMessage: ProviderMessage = {
id: "test-msg-123",
from: "@notallowed",
to: "@botuser",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
await messageHandler(testMessage);
}
});
// Start monitor
const _monitorPromise = monitorTelegramProvider(true, mockRuntime);
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify reply was NOT attempted (filtered out)
expect(getReplyFromConfig).not.toHaveBeenCalled();
expect(mockProvider.send).not.toHaveBeenCalled();
});
it("should handle media messages", async () => {
const { getReplyFromConfig } = await import("../auto-reply/reply.js");
// Mock reply with media
(getReplyFromConfig as Mock).mockResolvedValueOnce({
text: "Here's an image",
mediaUrl: "https://example.com/image.jpg",
});
// Mock startListening to send message with media
(mockProvider.startListening as Mock).mockImplementationOnce(async () => {
if (messageHandler) {
const testMessage: ProviderMessage = {
id: "test-msg-456",
from: "@testuser",
to: "@botuser",
body: "Send me a picture",
timestamp: Date.now(),
provider: "telegram",
media: [
{
type: "image",
url: "https://example.com/input.jpg",
mimeType: "image/jpeg",
},
],
};
await messageHandler(testMessage);
}
});
// Start monitor
const _monitorPromise = monitorTelegramProvider(false, mockRuntime);
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify reply with media was sent
expect(mockProvider.send).toHaveBeenCalledWith(
"@testuser",
"Here's an image",
expect.objectContaining({
media: expect.arrayContaining([
expect.objectContaining({
type: "image",
url: "https://example.com/image.jpg",
}),
]),
}),
);
});
it("should send typing indicator when onReplyStart is called", async () => {
const { getReplyFromConfig } = await import("../auto-reply/reply.js");
let capturedOnReplyStart: (() => Promise<void>) | null = null;
// Capture onReplyStart callback
(getReplyFromConfig as Mock).mockImplementationOnce(async (_ctx, opts) => {
capturedOnReplyStart = opts?.onReplyStart;
return { text: "Test reply" };
});
// Mock startListening
(mockProvider.startListening as Mock).mockImplementationOnce(async () => {
if (messageHandler) {
const testMessage: ProviderMessage = {
id: "test-msg-789",
from: "@testuser",
to: "@botuser",
body: "Hello",
timestamp: Date.now(),
provider: "telegram",
};
await messageHandler(testMessage);
}
});
// Start monitor
const _monitorPromise = monitorTelegramProvider(false, mockRuntime);
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 50));
// Call captured onReplyStart
if (capturedOnReplyStart) {
await capturedOnReplyStart();
}
// Verify typing indicator was sent
expect(mockProvider.sendTyping).toHaveBeenCalledWith("@testuser");
});
});

264
src/telegram/monitor.ts Normal file
View File

@ -0,0 +1,264 @@
/**
* Telegram Relay Monitor
*
* Monitors incoming Telegram messages and handles auto-reply via the Provider interface.
*/
import { chunkText } from "../auto-reply/chunk.js";
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
import type { MsgContext } from "../auto-reply/templating.js";
import { loadConfig } from "../config/config.js";
import { readEnv } from "../env.js";
import { danger, info, isVerbose, logVerbose, success } from "../globals.js";
import type { Provider } from "../providers/base/interface.js";
import type {
ProviderMessage,
TelegramProviderConfig,
} from "../providers/base/types.js";
import { createInitializedProvider } from "../providers/factory.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeAllowFromEntry } from "../utils.js";
const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit
/**
* Convert ProviderMessage to MsgContext for auto-reply system.
*/
function providerMessageToContext(message: ProviderMessage): MsgContext {
return {
Body: message.body,
From: message.from,
To: message.to,
MessageSid: message.id,
MediaUrl: message.media?.[0]?.url,
MediaType: message.media?.[0]?.mimeType,
MediaPath: message.media?.[0]?.fileName,
};
}
/**
* Send a reply payload via the provider.
*/
async function sendReply(
provider: Provider,
replyTo: string,
payload: ReplyPayload,
runtime: RuntimeEnv,
): Promise<void> {
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const text = payload.text ?? "";
const chunks = chunkText(text, TELEGRAM_TEXT_LIMIT);
if (chunks.length === 0 && mediaList.length === 0) {
return; // Nothing to send
}
// Send first chunk with first media (if any)
if (chunks.length > 0 || mediaList.length > 0) {
const firstChunk = chunks.length > 0 ? chunks[0] : "";
const firstMedia = mediaList[0];
try {
const result = await provider.send(replyTo, firstChunk, {
media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined,
});
if (isVerbose()) {
runtime.log(
success(
`↩️ Auto-replied to ${replyTo} via Telegram (id ${result.messageId})`,
),
);
}
} catch (err) {
runtime.error(danger(`Failed to send Telegram reply: ${String(err)}`));
throw err;
}
}
// Send remaining text chunks (no media)
for (let i = 1; i < chunks.length; i++) {
try {
await provider.send(replyTo, chunks[i]);
} catch (err) {
runtime.error(
danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`),
);
}
}
// Send remaining media (without text)
for (let i = 1; i < mediaList.length; i++) {
try {
await provider.send(replyTo, "", {
media: [{ type: "image", url: mediaList[i] }],
});
} catch (err) {
runtime.error(
danger(`Failed to send Telegram media ${i}: ${String(err)}`),
);
}
}
}
/**
* Handle an inbound message with auto-reply logic.
*/
async function handleInboundMessage(
message: ProviderMessage,
provider: Provider,
runtime: RuntimeEnv,
): Promise<void> {
const ctx = providerMessageToContext(message);
const config = loadConfig();
// Check allowFrom filter
const allowFrom = config.telegram?.allowFrom;
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
if (!allowFrom.includes("*")) {
const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram");
const normalizedAllowList = allowFrom.map((e) =>
normalizeAllowFromEntry(e, "telegram"),
);
if (!normalizedAllowList.includes(normalizedFrom)) {
if (isVerbose()) {
logVerbose(
`Skipping auto-reply: sender ${message.from} not in telegram.allowFrom list`,
);
}
return;
}
}
}
// Log inbound message
const timestamp = new Date(message.timestamp).toISOString();
runtime.log(
`\n[${timestamp}] ${message.from} -> ${message.to}: ${message.body}`,
);
// Get reply from config
const replyResult = await getReplyFromConfig(
ctx,
{
onReplyStart: async () => {
try {
await provider.sendTyping(message.from);
} catch {
// Typing indicator is optional
}
},
},
config,
);
// Handle replies
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
if (replies.length === 0) {
logVerbose("No auto-reply configured or reply was empty");
return;
}
// Send each reply
for (const payload of replies) {
await sendReply(provider, message.from, payload, runtime);
}
}
/**
* Start monitoring Telegram for inbound messages with auto-reply.
*
* This function:
* - Reads Telegram config from environment
* - Creates and initializes a TelegramProvider
* - Registers message handler with auto-reply logic
* - Starts listening for messages
* - Runs indefinitely until interrupted or abort signal fires
*
* @param verbose - Enable verbose logging
* @param runtime - Runtime environment (for testing)
* @param abortSignal - Optional AbortSignal to stop monitoring gracefully
*/
export async function monitorTelegramProvider(
verbose: boolean,
runtime: RuntimeEnv = defaultRuntime,
abortSignal?: AbortSignal,
suppressStartMessage = false,
): Promise<void> {
const env = readEnv(runtime);
const config = loadConfig();
if (!env.telegram?.apiId || !env.telegram?.apiHash) {
throw new Error(
"Telegram not configured. Set TELEGRAM_API_ID and TELEGRAM_API_HASH in .env",
);
}
const providerConfig: TelegramProviderConfig = {
kind: "telegram",
apiId: Number.parseInt(env.telegram.apiId, 10),
apiHash: env.telegram.apiHash,
sessionDir: undefined, // Uses default ~/.warelay/telegram
allowFrom: config.telegram?.allowFrom,
verbose,
};
runtime.log(info("📡 Starting Telegram relay..."));
// Create and initialize provider
const provider = await createInitializedProvider("telegram", providerConfig);
if (!provider.isConnected()) {
throw new Error("Failed to connect to Telegram");
}
const sessionId = await provider.getSessionId();
runtime.log(info(`✅ Connected as: ${sessionId ?? "unknown"}`));
// Set up message handler with auto-reply
provider.onMessage(async (message: ProviderMessage) => {
try {
await handleInboundMessage(message, provider, runtime);
} catch (err) {
runtime.error(danger(`Error handling Telegram message: ${String(err)}`));
}
});
// Start listening
await provider.startListening();
if (!suppressStartMessage) {
runtime.log(
info(
"✅ Telegram relay active. Listening for messages... (Ctrl+C to stop)",
),
);
}
// Keep process alive until abort signal or forever
if (abortSignal) {
await new Promise<void>((resolve) => {
if (abortSignal.aborted) {
resolve();
return;
}
abortSignal.addEventListener("abort", () => resolve());
});
runtime.log(info("📡 Telegram relay stopping..."));
} else {
// No abort signal: run indefinitely
await new Promise(() => {
// Never resolves - process runs until interrupted
});
}
}

View File

@ -0,0 +1,758 @@
/**
* Outbound Tests
*/
import type { TelegramClient } from "telegram";
import { Api } from "telegram";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderMedia, SendOptions } from "../providers/base/types.js";
import * as downloadModule from "./download.js";
import {
sendMediaMessage,
sendTextMessage,
sendTypingIndicator,
} from "./outbound.js";
import * as utilsModule from "./utils.js";
// Mock utils
vi.mock("./utils.js");
// Mock download module
vi.mock("./download.js");
// Mock global fetch
global.fetch = vi.fn();
describe("outbound", () => {
let mockClient: Partial<TelegramClient>;
let mockEntity: Api.User;
beforeEach(() => {
mockEntity = new Api.User({
id: BigInt(12345),
firstName: "Test",
});
mockClient = {
sendMessage: vi.fn(),
sendFile: vi.fn(),
invoke: vi.fn(),
};
vi.mocked(utilsModule.resolveEntity).mockResolvedValue(mockEntity);
vi.mocked(utilsModule.extractUserId).mockReturnValue("12345");
vi.clearAllMocks();
});
describe("sendTextMessage", () => {
it("sends text message with username", async () => {
const mockResult = {
id: 999,
message: "test message",
};
vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any);
const result = await sendTextMessage(
mockClient as TelegramClient,
"@testuser",
"test message",
);
expect(utilsModule.resolveEntity).toHaveBeenCalledWith(
mockClient,
"@testuser",
);
expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEntity, {
message: "test message",
replyTo: undefined,
});
expect(result).toEqual({
messageId: "999",
status: "sent",
providerMeta: {
jid: "12345",
},
});
});
it("sends text message with phone number", async () => {
const mockResult = {
id: 999,
message: "test message",
};
vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any);
const result = await sendTextMessage(
mockClient as TelegramClient,
"+1234567890",
"test message",
);
expect(utilsModule.resolveEntity).toHaveBeenCalledWith(
mockClient,
"+1234567890",
);
expect(result.messageId).toBe("999");
});
it("sends text message with replyTo option", async () => {
const mockResult = {
id: 999,
message: "test message",
};
vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any);
const options: SendOptions = {
replyTo: "123",
};
await sendTextMessage(
mockClient as TelegramClient,
"@testuser",
"test message",
options,
);
expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEntity, {
message: "test message",
replyTo: 123,
});
});
it("sends text message without options", async () => {
const mockResult = {
id: 999,
message: "test message",
};
vi.mocked(mockClient.sendMessage).mockResolvedValue(mockResult as any);
await sendTextMessage(
mockClient as TelegramClient,
"@testuser",
"test message",
);
expect(mockClient.sendMessage).toHaveBeenCalledWith(mockEntity, {
message: "test message",
replyTo: undefined,
});
});
});
describe("sendMediaMessage", () => {
it("sends image from buffer", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "image",
buffer: Buffer.from("fake-image-data"),
mimeType: "image/jpeg",
};
const result = await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Check this out!",
media,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, {
file: media.buffer,
caption: "Check this out!",
replyTo: undefined,
});
expect(result).toEqual({
messageId: "999",
status: "sent",
providerMeta: {
jid: "12345",
},
});
});
it("sends video with attributes", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "video",
buffer: Buffer.from("fake-video-data"),
mimeType: "video/mp4",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Watch this",
media,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(
mockEntity,
expect.objectContaining({
file: media.buffer,
caption: "Watch this",
attributes: expect.arrayContaining([
expect.any(Api.DocumentAttributeVideo),
]),
}),
);
});
it("sends audio file", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "audio",
buffer: Buffer.from("fake-audio-data"),
mimeType: "audio/mp3",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Listen to this",
media,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, {
file: media.buffer,
caption: "Listen to this",
replyTo: undefined,
voiceNote: false,
});
});
it("sends voice note with voiceNote flag", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "voice",
buffer: Buffer.from("fake-voice-data"),
mimeType: "audio/ogg",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"",
media,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, {
file: media.buffer,
caption: undefined,
replyTo: undefined,
voiceNote: true,
});
});
it("sends document with fileName attribute", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "document",
buffer: Buffer.from("fake-doc-data"),
fileName: "report.pdf",
mimeType: "application/pdf",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Here's the report",
media,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(
mockEntity,
expect.objectContaining({
file: media.buffer,
caption: "Here's the report",
attributes: expect.arrayContaining([
expect.any(Api.DocumentAttributeFilename),
]),
}),
);
});
it("sends document without fileName attribute", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "document",
buffer: Buffer.from("fake-doc-data"),
mimeType: "application/pdf",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Here's a file",
media,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, {
file: media.buffer,
caption: "Here's a file",
replyTo: undefined,
attributes: undefined,
});
});
it("downloads and sends media from URL", async () => {
const mockResult = {
id: 999,
};
const mockTempPath = "/tmp/test-image.tmp";
const mockCleanup = vi.fn().mockResolvedValue(undefined);
// Mock HEAD request for size check
const mockHeadResponse = {
headers: {
get: vi.fn((name: string) => {
if (name === "content-length") return "1024";
return null;
}),
},
};
vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any);
// Mock streamDownloadToTemp
vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({
tempPath: mockTempPath,
size: 1024,
contentType: "image/jpeg",
cleanup: mockCleanup,
});
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "image",
url: "https://example.com/image.jpg",
mimeType: "image/jpeg",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Look at this",
media,
);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/image.jpg",
{ method: "HEAD" },
);
expect(downloadModule.streamDownloadToTemp).toHaveBeenCalledWith(
"https://example.com/image.jpg",
expect.any(Number),
);
expect(mockClient.sendFile).toHaveBeenCalledWith(
mockEntity,
expect.objectContaining({
file: mockTempPath,
caption: "Look at this",
}),
);
expect(mockCleanup).toHaveBeenCalledTimes(1);
});
it("warns but proceeds when content-length header is missing", async () => {
const mockResult = {
id: 999,
};
const mockTempPath = "/tmp/test-chunked.tmp";
const mockCleanup = vi.fn().mockResolvedValue(undefined);
// Mock HEAD request without content-length
const mockHeadResponse = {
headers: {
get: vi.fn(() => null), // No content-length header
},
};
vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any);
// Mock streamDownloadToTemp
vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({
tempPath: mockTempPath,
size: 1024,
contentType: "image/jpeg",
cleanup: mockCleanup,
});
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "image",
url: "https://example.com/chunked.jpg",
};
const result = await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Image without size",
media,
);
// No warning expected now - download proceeds without size check
expect(result.messageId).toBe("999");
expect(mockCleanup).toHaveBeenCalledTimes(1);
});
it("falls back to GET when HEAD request fails", async () => {
const mockResult = {
id: 999,
};
const mockTempPath = "/tmp/test-no-head.tmp";
const mockCleanup = vi.fn().mockResolvedValue(undefined);
// Mock HEAD request failure (host blocks HEAD)
vi.mocked(global.fetch).mockRejectedValueOnce(
new Error("Method Not Allowed"),
);
// Mock streamDownloadToTemp
vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({
tempPath: mockTempPath,
size: 1024,
contentType: "image/jpeg",
cleanup: mockCleanup,
});
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation();
const media: ProviderMedia = {
type: "image",
url: "https://example.com/no-head.jpg",
};
const result = await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Image from host blocking HEAD",
media,
);
// Should warn about HEAD failure
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("HEAD request failed"),
);
// Should still succeed
expect(result.messageId).toBe("999");
expect(mockCleanup).toHaveBeenCalledTimes(1);
consoleWarnSpy.mockRestore();
});
it("throws error when URL download fails", async () => {
// Mock HEAD request success
const mockHeadResponse = {
headers: {
get: vi.fn((name: string) => {
if (name === "content-length") return "1024";
return null;
}),
},
};
vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any);
// Mock streamDownloadToTemp to throw
vi.mocked(downloadModule.streamDownloadToTemp).mockRejectedValue(
new Error(
"Failed to download media from https://example.com/missing.jpg: Not Found",
),
);
const media: ProviderMedia = {
type: "image",
url: "https://example.com/missing.jpg",
};
await expect(
sendMediaMessage(mockClient as TelegramClient, "@testuser", "", media),
).rejects.toThrow(
"Failed to download media from https://example.com/missing.jpg: Not Found",
);
});
it("throws error when media has neither buffer nor URL", async () => {
const media: ProviderMedia = {
type: "image",
mimeType: "image/jpeg",
};
await expect(
sendMediaMessage(mockClient as TelegramClient, "@testuser", "", media),
).rejects.toThrow("Media must have either buffer or url");
});
it("sends media with replyTo option", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "image",
buffer: Buffer.from("fake-image-data"),
};
const options: SendOptions = {
replyTo: "123",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Reply with image",
media,
options,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(mockEntity, {
file: media.buffer,
caption: "Reply with image",
replyTo: 123,
});
});
it("uses empty body as undefined caption", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "image",
buffer: Buffer.from("fake-image-data"),
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"",
media,
);
expect(mockClient.sendFile).toHaveBeenCalledWith(
mockEntity,
expect.objectContaining({
caption: undefined,
}),
);
});
describe("streaming downloads", () => {
it("downloads URL to temp file and cleans up after send", async () => {
const mockResult = {
id: 999,
};
const mockTempPath = "/tmp/test-file.tmp";
const mockCleanup = vi.fn().mockResolvedValue(undefined);
// Mock HEAD request
const mockHeadResponse = {
headers: {
get: vi.fn((name: string) => {
if (name === "content-length") return "1024";
return null;
}),
},
};
vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any);
// Mock streamDownloadToTemp
vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({
tempPath: mockTempPath,
size: 1024,
contentType: "image/jpeg",
cleanup: mockCleanup,
});
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "image",
url: "https://example.com/test.jpg",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Streamed image",
media,
);
// Verify streamDownloadToTemp called
expect(downloadModule.streamDownloadToTemp).toHaveBeenCalledWith(
"https://example.com/test.jpg",
expect.any(Number),
);
// Verify sendFile called with path (not buffer)
expect(mockClient.sendFile).toHaveBeenCalledWith(
mockEntity,
expect.objectContaining({
file: mockTempPath, // String path, not Buffer
caption: "Streamed image",
}),
);
// Verify cleanup was called
expect(mockCleanup).toHaveBeenCalledTimes(1);
});
it("cleans up temp file even when sendFile fails", async () => {
const mockTempPath = "/tmp/test-file.tmp";
const mockCleanup = vi.fn().mockResolvedValue(undefined);
// Mock HEAD request
const mockHeadResponse = {
headers: {
get: vi.fn(() => "1024"),
},
};
vi.mocked(global.fetch).mockResolvedValueOnce(mockHeadResponse as any);
// Mock streamDownloadToTemp
vi.mocked(downloadModule.streamDownloadToTemp).mockResolvedValue({
tempPath: mockTempPath,
size: 1024,
contentType: "image/jpeg",
cleanup: mockCleanup,
});
// Mock sendFile to fail
vi.mocked(mockClient.sendFile).mockRejectedValue(
new Error("Network error"),
);
const media: ProviderMedia = {
type: "image",
url: "https://example.com/test.jpg",
};
await expect(
sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Failed send",
media,
),
).rejects.toThrow("Network error");
// Verify cleanup was still called despite error
expect(mockCleanup).toHaveBeenCalledTimes(1);
});
it("preserves buffer-based media path (backward compat)", async () => {
const mockResult = {
id: 999,
};
vi.mocked(mockClient.sendFile).mockResolvedValue(mockResult as any);
const media: ProviderMedia = {
type: "image",
buffer: Buffer.from("test-buffer"),
mimeType: "image/jpeg",
};
await sendMediaMessage(
mockClient as TelegramClient,
"@testuser",
"Buffer image",
media,
);
// Verify streamDownloadToTemp NOT called for buffers
expect(downloadModule.streamDownloadToTemp).not.toHaveBeenCalled();
// Verify sendFile called with Buffer (not path)
expect(mockClient.sendFile).toHaveBeenCalledWith(
mockEntity,
expect.objectContaining({
file: media.buffer,
caption: "Buffer image",
}),
);
});
});
});
describe("sendTypingIndicator", () => {
it("sends typing indicator to user", async () => {
vi.mocked(mockClient.invoke).mockResolvedValue(undefined as any);
await sendTypingIndicator(mockClient as TelegramClient, "@testuser");
expect(utilsModule.resolveEntity).toHaveBeenCalledWith(
mockClient,
"@testuser",
);
expect(mockClient.invoke).toHaveBeenCalledWith(
expect.any(Api.messages.SetTyping),
);
});
it("sends typing indicator with phone number", async () => {
vi.mocked(mockClient.invoke).mockResolvedValue(undefined as any);
await sendTypingIndicator(mockClient as TelegramClient, "+1234567890");
expect(utilsModule.resolveEntity).toHaveBeenCalledWith(
mockClient,
"+1234567890",
);
});
it("uses SendMessageTypingAction", async () => {
vi.mocked(mockClient.invoke).mockResolvedValue(undefined as any);
await sendTypingIndicator(mockClient as TelegramClient, "@testuser");
const callArgs = vi.mocked(mockClient.invoke).mock.calls[0][0];
expect(callArgs).toBeInstanceOf(Api.messages.SetTyping);
expect(callArgs.peer).toBe(mockEntity);
expect(callArgs.action).toBeInstanceOf(Api.SendMessageTypingAction);
});
});
});

181
src/telegram/outbound.ts Normal file
View File

@ -0,0 +1,181 @@
import type { TelegramClient } from "telegram";
import { Api } from "telegram";
import type {
ProviderMedia,
SendOptions,
SendResult,
} from "../providers/base/types.js";
import { capabilities } from "./capabilities.js";
import { streamDownloadToTemp } from "./download.js";
import { extractUserId, resolveEntity } from "./utils.js";
/**
* Send a text message via Telegram.
*/
export async function sendTextMessage(
client: TelegramClient,
to: string,
body: string,
options?: SendOptions,
): Promise<SendResult> {
const entity = await resolveEntity(client, to);
const result = await client.sendMessage(entity, {
message: body,
replyTo: options?.replyTo ? Number(options.replyTo) : undefined,
});
return {
messageId: result.id.toString(),
status: "sent",
providerMeta: {
jid: extractUserId(entity),
},
};
}
/**
* Send a message with media attachment via Telegram.
*
* Media sources:
* - Buffer: Sent directly from memory (existing behavior)
* - URL: Streamed to temp file (~/.warelay/telegram-temp), sent, then cleaned up
*
* Streaming eliminates OOM risk for large files by avoiding memory buffering.
* Temp files are automatically cleaned up after send (success or failure).
*/
export async function sendMediaMessage(
client: TelegramClient,
to: string,
body: string,
media: ProviderMedia,
options?: SendOptions,
): Promise<SendResult> {
const entity = await resolveEntity(client, to);
// Determine file source
let file: Buffer | string;
let downloadCleanup: (() => Promise<void>) | undefined;
try {
if (media.buffer) {
// CASE 1: Buffer-based media (existing path)
file = media.buffer;
} else if (media.url) {
// CASE 2: URL-based media (streaming path)
// HEAD check for early size validation (best effort)
let contentLength: string | null = null;
try {
const headResponse = await fetch(media.url, { method: "HEAD" });
contentLength = headResponse.headers.get("content-length");
} catch (headError) {
// HEAD blocked or failed - warn but proceed with streaming download
console.warn(
`⚠️ HEAD request failed for ${media.url}, proceeding with streaming download: ${headError instanceof Error ? headError.message : String(headError)}`,
);
}
const maxSize = capabilities.maxMediaSize;
if (contentLength) {
const sizeBytes = Number.parseInt(contentLength, 10);
if (sizeBytes > maxSize) {
throw new Error(
`Media size ${(sizeBytes / 1024 / 1024).toFixed(1)}MB exceeds maximum ${(maxSize / 1024 / 1024).toFixed(0)}MB. ` +
"Lower limit with TELEGRAM_MAX_MEDIA_MB env var if needed.",
);
}
}
// Stream download to temp file (eliminates memory buffering)
const download = await streamDownloadToTemp(media.url, maxSize);
file = download.tempPath;
downloadCleanup = download.cleanup;
} else {
throw new Error("Media must have either buffer or url");
}
// Send based on media type
let result: { id: number };
const caption = body || undefined;
switch (media.type) {
case "image":
result = await client.sendFile(entity, {
file,
caption,
replyTo: options?.replyTo ? Number(options.replyTo) : undefined,
});
break;
case "video":
result = await client.sendFile(entity, {
file,
caption,
replyTo: options?.replyTo ? Number(options.replyTo) : undefined,
attributes: [
new Api.DocumentAttributeVideo({
duration: 0,
w: 0,
h: 0,
}),
],
});
break;
case "audio":
case "voice":
result = await client.sendFile(entity, {
file,
caption,
replyTo: options?.replyTo ? Number(options.replyTo) : undefined,
voiceNote: media.type === "voice",
});
break;
default:
result = await client.sendFile(entity, {
file,
caption,
replyTo: options?.replyTo ? Number(options.replyTo) : undefined,
attributes: media.fileName
? [
new Api.DocumentAttributeFilename({
fileName: media.fileName,
}),
]
: undefined,
});
break;
}
return {
messageId: result.id.toString(),
status: "sent",
providerMeta: {
jid: extractUserId(entity),
},
};
} finally {
// CRITICAL: Clean up temp file on success or failure
if (downloadCleanup) {
await downloadCleanup();
}
}
}
/**
* Send typing indicator to a chat.
*/
export async function sendTypingIndicator(
client: TelegramClient,
to: string,
): Promise<void> {
const entity = await resolveEntity(client, to);
await client.invoke(
new Api.messages.SetTyping({
peer: entity,
action: new Api.SendMessageTypingAction(),
}),
);
}

55
src/telegram/prompts.ts Normal file
View File

@ -0,0 +1,55 @@
import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
/**
* Prompt for phone number input.
*/
export async function promptPhone(
_runtime: RuntimeEnv = defaultRuntime,
): Promise<string> {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
const phone = await rl.question(
"📱 Enter your phone number (with country code, e.g., +1234567890): ",
);
return phone.trim();
} finally {
rl.close();
}
}
/**
* Prompt for SMS verification code.
*/
export async function promptSMSCode(
_runtime: RuntimeEnv = defaultRuntime,
): Promise<string> {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
const code = await rl.question(
"🔐 Enter the verification code from Telegram: ",
);
return code.trim();
} finally {
rl.close();
}
}
/**
* Prompt for 2FA password if enabled.
*/
export async function prompt2FA(
_runtime: RuntimeEnv = defaultRuntime,
): Promise<string> {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
const password = await rl.question(
"🔑 Enter your 2FA password (or leave empty if not enabled): ",
);
return password.trim();
} finally {
rl.close();
}
}

View File

@ -0,0 +1,506 @@
/**
* TelegramProvider Tests
*/
import type { TelegramClient } from "telegram";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
ProviderMedia,
TelegramProviderConfig,
} from "../providers/base/types.js";
import { capabilities } from "./capabilities.js";
import * as clientModule from "./client.js";
import * as inboundModule from "./inbound.js";
import * as loginModule from "./login.js";
import * as outboundModule from "./outbound.js";
import { TelegramProvider } from "./provider.js";
import * as sessionModule from "./session.js";
// Mock all dependencies
vi.mock("./session.js");
vi.mock("./client.js");
vi.mock("./login.js");
vi.mock("./outbound.js");
vi.mock("./inbound.js");
describe("TelegramProvider", () => {
let provider: TelegramProvider;
let mockClient: Partial<TelegramClient>;
beforeEach(() => {
provider = new TelegramProvider();
mockClient = {
connected: true,
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
getMe: vi.fn().mockResolvedValue({
username: "testuser",
firstName: "Test",
phone: "+1234567890",
}),
};
vi.clearAllMocks();
});
describe("Provider Properties", () => {
it("has correct kind", () => {
expect(provider.kind).toBe("telegram");
});
it("has correct capabilities", () => {
expect(provider.capabilities).toEqual(capabilities);
expect(provider.capabilities.supportsDeliveryReceipts).toBe(false);
expect(provider.capabilities.supportsReadReceipts).toBe(false);
expect(provider.capabilities.supportsTypingIndicator).toBe(true);
expect(provider.capabilities.maxMediaSize).toBe(2 * 1024 * 1024 * 1024);
});
});
describe("initialize", () => {
it("initializes successfully with valid config and session", async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
verbose: false,
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
await provider.initialize(config);
expect(sessionModule.loadSession).toHaveBeenCalled();
expect(clientModule.createTelegramClient).toHaveBeenCalledWith({}, false);
expect(mockClient.connect).toHaveBeenCalled();
});
it("throws error with invalid config kind", async () => {
const config = {
kind: "web" as any,
verbose: false,
};
await expect(provider.initialize(config)).rejects.toThrow(
"Invalid config kind for TelegramProvider: web",
);
});
it("throws error when no session exists", async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
};
vi.mocked(sessionModule.loadSession).mockResolvedValue(null);
await expect(provider.initialize(config)).rejects.toThrow(
"No Telegram session found. Run: warelay login --provider telegram",
);
});
it("throws error when connection fails", async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue({
...mockClient,
connected: false,
} as TelegramClient);
await expect(provider.initialize(config)).rejects.toThrow(
"Failed to connect to Telegram",
);
});
it("passes verbose flag to client", async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
verbose: true,
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
await provider.initialize(config);
expect(clientModule.createTelegramClient).toHaveBeenCalledWith({}, true);
});
});
describe("Connection Management", () => {
beforeEach(async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
await provider.initialize(config);
});
it("isConnected returns true when client is connected", () => {
vi.mocked(clientModule.isClientConnected).mockReturnValue(true);
expect(provider.isConnected()).toBe(true);
});
it("isConnected returns false when client is not connected", () => {
vi.mocked(clientModule.isClientConnected).mockReturnValue(false);
expect(provider.isConnected()).toBe(false);
});
it("isConnected returns false when no client exists", () => {
const uninitializedProvider = new TelegramProvider();
expect(uninitializedProvider.isConnected()).toBe(false);
});
it("disconnects client and clears reference", async () => {
await provider.disconnect();
expect(mockClient.disconnect).toHaveBeenCalled();
expect(provider.isConnected()).toBe(false);
});
it("disconnect handles null client gracefully", async () => {
const uninitializedProvider = new TelegramProvider();
await expect(uninitializedProvider.disconnect()).resolves.not.toThrow();
});
});
describe("Message Sending", () => {
beforeEach(async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
await provider.initialize(config);
});
it("send sends text message when no media provided", async () => {
const mockResult = {
messageId: "999",
status: "sent" as const,
};
vi.mocked(outboundModule.sendTextMessage).mockResolvedValue(mockResult);
const result = await provider.send("@testuser", "Hello!");
expect(outboundModule.sendTextMessage).toHaveBeenCalledWith(
mockClient,
"@testuser",
"Hello!",
undefined,
);
expect(result).toEqual(mockResult);
});
it("send sends media message when media provided", async () => {
const mockResult = {
messageId: "999",
status: "sent" as const,
};
vi.mocked(outboundModule.sendMediaMessage).mockResolvedValue(mockResult);
const media: ProviderMedia = {
type: "image",
buffer: Buffer.from("test"),
};
const result = await provider.send("@testuser", "Check this out!", {
media: [media],
});
expect(outboundModule.sendMediaMessage).toHaveBeenCalledWith(
mockClient,
"@testuser",
"Check this out!",
media,
{ media: [media] },
);
expect(result).toEqual(mockResult);
});
it("send throws error when provider not initialized", async () => {
const uninitializedProvider = new TelegramProvider();
await expect(
uninitializedProvider.send("@testuser", "Hello!"),
).rejects.toThrow("Provider not initialized");
});
it("sendTyping sends typing indicator", async () => {
vi.mocked(outboundModule.sendTypingIndicator).mockResolvedValue(
undefined,
);
await provider.sendTyping("@testuser");
expect(outboundModule.sendTypingIndicator).toHaveBeenCalledWith(
mockClient,
"@testuser",
);
});
it("sendTyping throws error when provider not initialized", async () => {
const uninitializedProvider = new TelegramProvider();
await expect(
uninitializedProvider.sendTyping("@testuser"),
).rejects.toThrow("Provider not initialized");
});
it("getDeliveryStatus returns unknown status", async () => {
const status = await provider.getDeliveryStatus("test-id");
expect(status.messageId).toBe("test-id");
expect(status.status).toBe("unknown");
expect(status.timestamp).toBeGreaterThan(0);
});
});
describe("Message Listening", () => {
beforeEach(async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
await provider.initialize(config);
});
it("onMessage stores handler", () => {
const handler = vi.fn();
provider.onMessage(handler);
// No way to directly test private field, but it shouldn't throw
});
it("startListening works when handler is registered", async () => {
const handler = vi.fn();
const mockCleanup = vi.fn();
vi.mocked(inboundModule.startMessageListener).mockResolvedValue(
mockCleanup,
);
provider.onMessage(handler);
await provider.startListening();
expect(inboundModule.startMessageListener).toHaveBeenCalledWith(
mockClient,
handler,
undefined,
);
});
it("startListening throws error when provider not initialized", async () => {
const uninitializedProvider = new TelegramProvider();
const handler = vi.fn();
uninitializedProvider.onMessage(handler);
await expect(uninitializedProvider.startListening()).rejects.toThrow(
"Provider not initialized",
);
});
it("startListening throws error when no message handler registered", async () => {
await expect(provider.startListening()).rejects.toThrow(
"No message handler registered. Call onMessage() first.",
);
});
it("startListening passes allowFrom config to listener", async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
allowFrom: ["@testuser", "+1234567890"],
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
const providerWithAllowFrom = new TelegramProvider();
await providerWithAllowFrom.initialize(config);
const handler = vi.fn();
const mockCleanup = vi.fn();
vi.mocked(inboundModule.startMessageListener).mockResolvedValue(
mockCleanup,
);
providerWithAllowFrom.onMessage(handler);
await providerWithAllowFrom.startListening();
expect(inboundModule.startMessageListener).toHaveBeenCalledWith(
mockClient,
handler,
["@testuser", "+1234567890"],
);
});
it("stopListening calls cleanup function", async () => {
const handler = vi.fn();
const mockCleanup = vi.fn();
vi.mocked(inboundModule.startMessageListener).mockResolvedValue(
mockCleanup,
);
provider.onMessage(handler);
await provider.startListening();
await provider.stopListening();
expect(mockCleanup).toHaveBeenCalled();
});
it("stopListening handles no active listener gracefully", async () => {
await expect(provider.stopListening()).resolves.not.toThrow();
});
it("stopListening can be called multiple times", async () => {
const handler = vi.fn();
const mockCleanup = vi.fn();
vi.mocked(inboundModule.startMessageListener).mockResolvedValue(
mockCleanup,
);
provider.onMessage(handler);
await provider.startListening();
await provider.stopListening();
await provider.stopListening();
expect(mockCleanup).toHaveBeenCalledTimes(1);
});
});
describe("Authentication", () => {
it("isAuthenticated delegates to telegramAuthExists", async () => {
vi.mocked(sessionModule.telegramAuthExists).mockResolvedValue(true);
const result = await provider.isAuthenticated();
expect(result).toBe(true);
expect(sessionModule.telegramAuthExists).toHaveBeenCalled();
});
it("login delegates to loginTelegram", async () => {
vi.mocked(loginModule.loginTelegram).mockResolvedValue(undefined);
await provider.login();
expect(loginModule.loginTelegram).toHaveBeenCalledWith(false);
});
it("logout delegates to logoutTelegram", async () => {
vi.mocked(loginModule.logoutTelegram).mockResolvedValue(undefined);
await provider.logout();
expect(loginModule.logoutTelegram).toHaveBeenCalledWith(false);
});
it("login passes verbose flag", async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
verbose: true,
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
await provider.initialize(config);
vi.mocked(loginModule.loginTelegram).mockResolvedValue(undefined);
await provider.login();
expect(loginModule.loginTelegram).toHaveBeenCalledWith(true);
});
});
describe("getSessionId", () => {
beforeEach(async () => {
const config: TelegramProviderConfig = {
kind: "telegram",
apiId: 12345,
apiHash: "test-hash",
};
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
vi.mocked(clientModule.createTelegramClient).mockResolvedValue(
mockClient as TelegramClient,
);
await provider.initialize(config);
});
it("returns username with @ prefix when available", async () => {
const sessionId = await provider.getSessionId();
expect(sessionId).toBe("@testuser");
});
it("returns phone when username not available", async () => {
mockClient.getMe = vi.fn().mockResolvedValue({
firstName: "Test",
phone: "+1234567890",
});
const sessionId = await provider.getSessionId();
expect(sessionId).toBe("+1234567890");
});
it("returns null when neither username nor phone available", async () => {
mockClient.getMe = vi.fn().mockResolvedValue({
firstName: "Test",
});
const sessionId = await provider.getSessionId();
expect(sessionId).toBe(null);
});
it("returns null when client is not initialized", async () => {
const uninitializedProvider = new TelegramProvider();
const sessionId = await uninitializedProvider.getSessionId();
expect(sessionId).toBe(null);
});
it("returns null when getMe throws error", async () => {
mockClient.getMe = vi.fn().mockRejectedValue(new Error("API error"));
const sessionId = await provider.getSessionId();
expect(sessionId).toBe(null);
});
});
});

176
src/telegram/provider.ts Normal file
View File

@ -0,0 +1,176 @@
import type { TelegramClient } from "telegram";
import type {
DeliveryStatus,
MessageHandler,
Provider,
ProviderConfig,
SendOptions,
SendResult,
TelegramProviderConfig,
} from "../providers/base/index.js";
import { capabilities } from "./capabilities.js";
import { createTelegramClient, isClientConnected } from "./client.js";
import { cleanOrphanedTempFiles } from "./download.js";
import { startMessageListener } from "./inbound.js";
import { loginTelegram, logoutTelegram } from "./login.js";
import {
sendMediaMessage,
sendTextMessage,
sendTypingIndicator,
} from "./outbound.js";
import { loadSession, telegramAuthExists } from "./session.js";
/**
* Telegram MTProto Provider
*
* Implements the unified Provider interface for Telegram using MTProto (GramJS).
* Handles personal account automation for 1-on-1 conversations.
*/
export class TelegramProvider implements Provider {
readonly kind = "telegram";
readonly capabilities = capabilities;
private client: TelegramClient | null = null;
private config: TelegramProviderConfig | null = null;
private messageHandler: MessageHandler | null = null;
private cleanupListener: (() => void) | null = null;
private verbose = false;
async initialize(config: ProviderConfig): Promise<void> {
if (config.kind !== "telegram") {
throw new Error(
`Invalid config kind for TelegramProvider: ${config.kind}`,
);
}
this.config = config;
this.verbose = config.verbose ?? false;
// Clean up orphaned temp files from previous crashes
await cleanOrphanedTempFiles();
// Load session
const session = await loadSession();
if (!session) {
throw new Error(
"No Telegram session found. Run: warelay login --provider telegram",
);
}
// Create and connect client
this.client = await createTelegramClient(session, this.verbose);
await this.client.connect();
if (!this.client.connected) {
throw new Error("Failed to connect to Telegram");
}
}
isConnected(): boolean {
return this.client !== null && isClientConnected(this.client);
}
async disconnect(): Promise<void> {
if (this.client) {
await this.client.disconnect();
this.client = null;
}
}
async send(
to: string,
body: string,
options?: SendOptions,
): Promise<SendResult> {
if (!this.client) {
throw new Error("Provider not initialized");
}
// If media provided, send with media
if (options?.media && options.media.length > 0) {
const media = options.media[0]; // Take first media attachment
return await sendMediaMessage(this.client, to, body, media, options);
}
// Otherwise send text only
return await sendTextMessage(this.client, to, body, options);
}
async sendTyping(to: string): Promise<void> {
if (!this.client) {
throw new Error("Provider not initialized");
}
await sendTypingIndicator(this.client, to);
}
async getDeliveryStatus(messageId: string): Promise<DeliveryStatus> {
// Telegram MTProto doesn't provide reliable delivery tracking
// Messages are sent optimistically, so we return "unknown"
return {
messageId,
status: "unknown",
timestamp: Date.now(),
};
}
onMessage(handler: MessageHandler): void {
this.messageHandler = handler;
}
async startListening(): Promise<void> {
if (!this.client) {
throw new Error("Provider not initialized");
}
if (!this.messageHandler) {
throw new Error("No message handler registered. Call onMessage() first.");
}
const allowFrom = this.config?.allowFrom;
this.cleanupListener = await startMessageListener(
this.client,
this.messageHandler,
allowFrom,
);
}
async stopListening(): Promise<void> {
if (this.cleanupListener) {
this.cleanupListener();
this.cleanupListener = null;
}
}
async isAuthenticated(): Promise<boolean> {
return await telegramAuthExists();
}
async login(): Promise<void> {
await loginTelegram(this.verbose);
}
async logout(): Promise<void> {
await logoutTelegram(this.verbose);
}
async getSessionId(): Promise<string | null> {
if (!this.client) {
return null;
}
try {
const me = await this.client.getMe();
if ("username" in me && typeof me.username === "string") {
return `@${me.username}`;
}
if ("phone" in me && typeof me.phone === "string") {
return me.phone;
}
return null;
} catch {
return null;
}
}
}

View File

@ -0,0 +1,134 @@
import fs from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:fs/promises");
vi.mock("telegram/sessions/index.js", () => ({
StringSession: class {
_sessionString: string;
constructor(sessionString = "") {
this._sessionString = sessionString;
}
save() {
return this._sessionString || "mock-session-string";
}
},
}));
vi.mock("../utils.js", () => ({
ensureDir: vi.fn().mockResolvedValue(undefined),
}));
import {
clearSession,
loadSession,
saveSession,
telegramAuthExists,
} from "./session.js";
describe("telegram session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("loadSession", () => {
it("returns null when session file does not exist", async () => {
vi.mocked(fs.readFile).mockRejectedValue(
Object.assign(new Error("ENOENT"), {
code: "ENOENT",
}),
);
const result = await loadSession();
expect(result).toBeNull();
expect(fs.readFile).toHaveBeenCalled();
});
it("loads session from disk when file exists", async () => {
const sessionString = "test-session-string";
vi.mocked(fs.readFile).mockResolvedValue(` ${sessionString} ` as never);
const result = await loadSession();
expect(result).toBeDefined();
expect(result?._sessionString).toBe(sessionString);
expect(fs.readFile).toHaveBeenCalled();
});
it("throws on unexpected errors", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
await expect(loadSession()).rejects.toThrow("Permission denied");
});
});
describe("saveSession", () => {
it("saves session to disk", async () => {
const mockSession = {
save: vi.fn(() => "saved-session-string"),
};
vi.mocked(fs.writeFile).mockResolvedValue(undefined as never);
await saveSession(mockSession as never);
expect(mockSession.save).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("session.string"),
"saved-session-string",
"utf-8",
);
});
});
describe("clearSession", () => {
it("removes session file when it exists", async () => {
vi.mocked(fs.unlink).mockResolvedValue(undefined as never);
await clearSession();
expect(fs.unlink).toHaveBeenCalledWith(
expect.stringContaining("session.string"),
);
});
it("does not throw when file does not exist", async () => {
vi.mocked(fs.unlink).mockRejectedValue(
Object.assign(new Error("ENOENT"), {
code: "ENOENT",
}),
);
await expect(clearSession()).resolves.toBeUndefined();
});
it("throws on unexpected errors", async () => {
vi.mocked(fs.unlink).mockRejectedValue(new Error("Permission denied"));
await expect(clearSession()).rejects.toThrow("Permission denied");
});
});
describe("telegramAuthExists", () => {
it("returns true when session file exists", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined as never);
const result = await telegramAuthExists();
expect(result).toBe(true);
expect(fs.access).toHaveBeenCalledWith(
expect.stringContaining("session.string"),
);
});
it("returns false when session file does not exist", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
const result = await telegramAuthExists();
expect(result).toBe(false);
});
});
});

119
src/telegram/session.ts Normal file
View File

@ -0,0 +1,119 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { StringSession } from "telegram/sessions/index.js";
import { ensureDir } from "../utils.js";
// New branding path (preferred)
const TELEGRAM_SESSION_DIR_CLAWDIS = path.join(
os.homedir(),
".clawdis",
"telegram",
"session",
);
// Legacy path (fallback for backward compatibility)
const TELEGRAM_SESSION_DIR_LEGACY = path.join(
os.homedir(),
".warelay",
"telegram",
"session",
);
// Exported for backward compatibility
export const TELEGRAM_SESSION_DIR = TELEGRAM_SESSION_DIR_LEGACY;
/**
* Resolve the Telegram session directory path.
* Prefers ~/.clawdis/telegram/session, falls back to ~/.warelay/telegram/session
*/
function resolveSessionDir(): string {
try {
// Synchronous check for CLAWDIS path
const clawdisSession = path.join(
TELEGRAM_SESSION_DIR_CLAWDIS,
"session.string",
);
if (fsSync.existsSync(clawdisSession)) {
return TELEGRAM_SESSION_DIR_CLAWDIS;
}
} catch {
// Fall through to legacy path
}
return TELEGRAM_SESSION_DIR_LEGACY;
}
/**
* Load Telegram session from disk.
* Returns null if no session exists.
*/
export async function loadSession(): Promise<StringSession | null> {
try {
const sessionDir = resolveSessionDir();
await ensureDir(sessionDir);
const sessionFile = path.join(sessionDir, "session.string");
const sessionString = await fs.readFile(sessionFile, "utf-8");
return new StringSession(sessionString.trim());
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null; // No session file exists yet
}
throw err; // Unexpected error
}
}
/**
* Save Telegram session to disk.
* Prefers saving to ~/.clawdis/telegram/session (new location).
*/
export async function saveSession(session: StringSession): Promise<void> {
// Always save to new CLAWDIS path for new sessions
await ensureDir(TELEGRAM_SESSION_DIR_CLAWDIS);
const sessionString = session.save();
const sessionFile = path.join(TELEGRAM_SESSION_DIR_CLAWDIS, "session.string");
await fs.writeFile(sessionFile, sessionString, "utf-8");
}
/**
* Clear Telegram session from disk.
* Removes session from both CLAWDIS and legacy paths.
*/
export async function clearSession(): Promise<void> {
const paths = [
path.join(TELEGRAM_SESSION_DIR_CLAWDIS, "session.string"),
path.join(TELEGRAM_SESSION_DIR_LEGACY, "session.string"),
];
for (const sessionPath of paths) {
try {
await fs.unlink(sessionPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
// Ignore ENOENT (file doesn't exist), but throw other errors
throw err;
}
}
}
}
/**
* Check if a Telegram session exists.
* Checks both CLAWDIS and legacy paths.
*/
export async function telegramAuthExists(): Promise<boolean> {
const paths = [
path.join(TELEGRAM_SESSION_DIR_CLAWDIS, "session.string"),
path.join(TELEGRAM_SESSION_DIR_LEGACY, "session.string"),
];
for (const sessionPath of paths) {
try {
await fs.access(sessionPath);
return true; // Found a session file
} catch {
// Continue checking other paths
}
}
return false; // No session found in any path
}

169
src/telegram/utils.test.ts Normal file
View File

@ -0,0 +1,169 @@
/**
* Utils Tests
*/
import type { TelegramClient } from "telegram";
import { Api } from "telegram";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { extractUserId, resolveEntity } from "./utils.js";
describe("utils", () => {
let mockClient: Partial<TelegramClient>;
beforeEach(() => {
mockClient = {
getEntity: vi.fn(),
};
vi.clearAllMocks();
});
describe("resolveEntity", () => {
it("resolves entity with @username", async () => {
const mockUser = new Api.User({
id: BigInt(12345),
firstName: "Test",
});
vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser);
const result = await resolveEntity(
mockClient as TelegramClient,
"@testuser",
);
expect(result).toBe(mockUser);
expect(mockClient.getEntity).toHaveBeenCalledWith("@testuser");
});
it("resolves entity with phone number", async () => {
const mockUser = new Api.User({
id: BigInt(12345),
firstName: "Test",
phone: "1234567890",
});
vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser);
const result = await resolveEntity(
mockClient as TelegramClient,
"+1234567890",
);
expect(result).toBe(mockUser);
expect(mockClient.getEntity).toHaveBeenCalledWith("+1234567890");
});
it("resolves entity with user ID", async () => {
const mockUser = new Api.User({
id: BigInt(12345),
firstName: "Test",
});
vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser);
const result = await resolveEntity(mockClient as TelegramClient, "12345");
expect(result).toBe(mockUser);
expect(mockClient.getEntity).toHaveBeenCalledWith("12345");
});
it("adds @ prefix automatically if first attempt fails", async () => {
const mockUser = new Api.User({
id: BigInt(12345),
firstName: "Test",
});
vi.mocked(mockClient.getEntity)
.mockRejectedValueOnce(new Error("Not found"))
.mockResolvedValueOnce(mockUser);
const result = await resolveEntity(
mockClient as TelegramClient,
"testuser",
);
expect(result).toBe(mockUser);
expect(mockClient.getEntity).toHaveBeenCalledTimes(2);
expect(mockClient.getEntity).toHaveBeenNthCalledWith(1, "testuser");
expect(mockClient.getEntity).toHaveBeenNthCalledWith(2, "@testuser");
});
it("does not add @ prefix if identifier already starts with @", async () => {
vi.mocked(mockClient.getEntity).mockRejectedValue(new Error("Not found"));
await expect(
resolveEntity(mockClient as TelegramClient, "@testuser"),
).rejects.toThrow("Could not resolve Telegram entity: @testuser");
expect(mockClient.getEntity).toHaveBeenCalledTimes(1);
expect(mockClient.getEntity).toHaveBeenCalledWith("@testuser");
});
it("throws descriptive error when entity cannot be resolved", async () => {
vi.mocked(mockClient.getEntity).mockRejectedValue(new Error("Not found"));
await expect(
resolveEntity(mockClient as TelegramClient, "unknown"),
).rejects.toThrow(
"Could not resolve Telegram entity: unknown. Use @username, phone number (+1234567890), or user ID.",
);
});
it("trims whitespace from identifier", async () => {
const mockUser = new Api.User({
id: BigInt(12345),
firstName: "Test",
});
vi.mocked(mockClient.getEntity).mockResolvedValue(mockUser);
await resolveEntity(mockClient as TelegramClient, " @testuser ");
expect(mockClient.getEntity).toHaveBeenCalledWith("@testuser");
});
});
describe("extractUserId", () => {
it("extracts user ID from User entity as string", () => {
const mockUser = new Api.User({
id: BigInt(12345),
firstName: "Test",
});
const userId = extractUserId(mockUser);
expect(userId).toBe("12345");
});
it("extracts user ID from Chat entity as string", () => {
const mockChat = new Api.Chat({
id: BigInt(67890),
title: "Test Chat",
});
const userId = extractUserId(mockChat);
expect(userId).toBe("67890");
});
it("returns '0' for entity without id field", () => {
const mockEntity = {} as Api.User;
const userId = extractUserId(mockEntity);
expect(userId).toBe("0");
});
it("returns '0' for entity with non-bigint id", () => {
const mockEntity = { id: "12345" } as unknown as Api.User;
const userId = extractUserId(mockEntity);
expect(userId).toBe("0");
});
it("handles large user IDs without precision loss", () => {
const mockUser = new Api.User({
id: BigInt("9007199254740992"), // MAX_SAFE_INTEGER + 1
firstName: "Test",
});
const userId = extractUserId(mockUser);
expect(userId).toBe("9007199254740992");
});
});
});

48
src/telegram/utils.ts Normal file
View File

@ -0,0 +1,48 @@
import type { Api, TelegramClient } from "telegram";
// Entity type - can be User, Chat, Channel, or their empty variants
type Entity = Api.User | Api.Chat | Api.Channel;
/**
* Resolve Telegram entity (user/chat) from identifier.
* Supports @username, phone number, or user ID.
*/
export async function resolveEntity(
client: TelegramClient,
identifier: string,
): Promise<Entity> {
// Clean identifier
const clean = identifier.trim();
// Try as-is first (handles @username, phone, user ID)
try {
return (await client.getEntity(clean)) as Entity;
} catch (_firstErr) {
// If not @ prefix, try adding it
if (!clean.startsWith("@")) {
try {
return (await client.getEntity(`@${clean}`)) as Entity;
} catch {
// Fall through to error
}
}
throw new Error(
`Could not resolve Telegram entity: ${identifier}. ` +
"Use @username, phone number (+1234567890), or user ID.",
);
}
}
/**
* Extract user ID from entity as string to avoid precision loss.
* Telegram IDs are bigint and can exceed Number.MAX_SAFE_INTEGER.
*/
export function extractUserId(entity: Entity): string {
// Extract ID as unknown to bypass TypeScript's overly narrow type inference
const id = ("id" in entity ? entity.id : null) as unknown;
if (typeof id === "bigint") {
return id.toString();
}
return "0";
}

View File

@ -3,6 +3,12 @@ import type { EnvConfig } from "../env.js";
export function createClient(env: EnvConfig) {
// Twilio client using either auth token or API key/secret.
if (!env.auth || !env.accountSid) {
throw new Error(
"Twilio credentials not configured. Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN (or TWILIO_API_KEY/TWILIO_API_SECRET) in .env",
);
}
if ("authToken" in env.auth) {
return Twilio(env.accountSid, env.auth.authToken, {
accountSid: env.accountSid,

View File

@ -43,7 +43,7 @@ export async function listRecentMessages(
): Promise<ListedMessage[]> {
const env = readEnv();
const client = clientOverride ?? createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const from = withWhatsAppPrefix(env.whatsappFrom!);
const since = new Date(Date.now() - lookbackMinutes * 60_000);
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.

View File

@ -60,7 +60,7 @@ export async function monitorTwilio(
let backoffMs = 1_000;
const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom);
const from = withWhatsAppPrefix(env.whatsappFrom!);
const client = opts?.client ?? deps.createClient(env);
logInfo(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,

View File

@ -17,7 +17,7 @@ export async function sendMessage(
) {
const env = readEnv(runtime);
const client = createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const from = withWhatsAppPrefix(env.whatsappFrom!);
const toNumber = withWhatsAppPrefix(to);
try {

View File

@ -11,7 +11,7 @@ export async function findIncomingNumberSid(
// Look up incoming phone number SID matching the configured WhatsApp number.
try {
const env = readEnv();
const phone = env.whatsappFrom.replace("whatsapp:", "");
const phone = env.whatsappFrom!.replace("whatsapp:", "");
const list = await client.incomingPhoneNumbers.list({
phoneNumber: phone,
limit: 1,
@ -29,7 +29,7 @@ export async function findMessagingServiceSid(
type IncomingNumberWithService = { messagingServiceSid?: string };
try {
const env = readEnv();
const phone = env.whatsappFrom.replace("whatsapp:", "");
const phone = env.whatsappFrom!.replace("whatsapp:", "");
const list = await client.incomingPhoneNumbers.list({
phoneNumber: phone,
limit: 1,

View File

@ -151,6 +151,9 @@ export async function startWebhook(
}
function buildTwilioBasicAuth(env: EnvConfig) {
if (!env.auth || !env.accountSid) {
throw new Error("Twilio credentials not configured for webhook authentication");
}
if ("authToken" in env.auth) {
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
"base64",

View File

@ -7,14 +7,32 @@ export async function ensureDir(dir: string) {
await fs.promises.mkdir(dir, { recursive: true });
}
export type Provider = "twilio" | "web";
export type Provider = "twilio" | "web" | "telegram";
export function assertProvider(input: string): asserts input is Provider {
if (input !== "twilio" && input !== "web") {
throw new Error("Provider must be 'twilio' or 'web'");
if (input !== "twilio" && input !== "web" && input !== "telegram") {
throw new Error("Provider must be 'web', 'twilio', or 'telegram'");
}
}
export type AllowFromProvider = "telegram" | "web" | "twilio";
export function normalizeAllowFromEntry(
entry: string,
provider: AllowFromProvider,
): string {
const trimmed = entry.trim().toLowerCase();
if (!trimmed) return "";
if (provider === "telegram") {
// Telegram uses @username format
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
}
// WhatsApp (both web and twilio) use E.164 phone numbers
return normalizeE164(entry);
}
export function normalizePath(p: string): string {
if (!p.startsWith("/")) return `/${p}`;
return p;

View File

@ -15,10 +15,13 @@ import { SESSION_STORE_DEFAULT } from "../config/sessions.js";
import { danger, info, success } from "../globals.js";
import { getChildLogger } from "../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { telegramAuthExists } from "../telegram/session.js";
import type { Provider } from "../utils.js";
import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js";
import { VERSION } from "../version.js";
export { telegramAuthExists };
export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials");
/**
@ -212,9 +215,15 @@ export function logWebSelfId(
}
export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
// Auto-select web when logged in; otherwise fall back to twilio.
// Auto-select web when logged in; otherwise fall back to telegram or twilio.
if (pref !== "auto") return pref;
// Priority: web > telegram > twilio
const hasWeb = await webAuthExists();
if (hasWeb) return "web";
const hasTelegram = await telegramAuthExists();
if (hasTelegram) return "telegram";
return "twilio";
}