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:
parent
20cb709ae3
commit
69608fd305
1035
docs/architecture/telegram-integration.md
Normal file
1035
docs/architecture/telegram-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
497
docs/telegram.md
Normal file
497
docs/telegram.md
Normal 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.
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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})` : ""}`,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
71
src/env.ts
71
src/env.ts
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
8
src/providers/base/index.ts
Normal file
8
src/providers/base/index.ts
Normal 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";
|
||||
120
src/providers/base/interface.ts
Normal file
120
src/providers/base/interface.ts
Normal 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
249
src/providers/base/types.ts
Normal 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
50
src/providers/factory.ts
Normal 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;
|
||||
}
|
||||
35
src/telegram/capabilities.test.ts
Normal file
35
src/telegram/capabilities.test.ts
Normal 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("*/*");
|
||||
});
|
||||
});
|
||||
72
src/telegram/capabilities.ts
Normal file
72
src/telegram/capabilities.ts
Normal 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
132
src/telegram/client.test.ts
Normal 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
48
src/telegram/client.ts
Normal 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;
|
||||
}
|
||||
335
src/telegram/download.test.ts
Normal file
335
src/telegram/download.test.ts
Normal 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
174
src/telegram/download.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
876
src/telegram/inbound.test.ts
Normal file
876
src/telegram/inbound.test.ts
Normal 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
250
src/telegram/inbound.ts
Normal 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
16
src/telegram/index.ts
Normal 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
306
src/telegram/login.test.ts
Normal 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
94
src/telegram/login.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
338
src/telegram/monitor.test.ts
Normal file
338
src/telegram/monitor.test.ts
Normal 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
264
src/telegram/monitor.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
758
src/telegram/outbound.test.ts
Normal file
758
src/telegram/outbound.test.ts
Normal 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
181
src/telegram/outbound.ts
Normal 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
55
src/telegram/prompts.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
506
src/telegram/provider.test.ts
Normal file
506
src/telegram/provider.test.ts
Normal 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
176
src/telegram/provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/telegram/session.test.ts
Normal file
134
src/telegram/session.test.ts
Normal 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
119
src/telegram/session.ts
Normal 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
169
src/telegram/utils.test.ts
Normal 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
48
src/telegram/utils.ts
Normal 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";
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)`,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
24
src/utils.ts
24
src/utils.ts
@ -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;
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user