mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-05-13 15:46:00 +00:00
Merge pull request #16 from klich3/main
Telegram Long Pulling + Update readme
This commit is contained in:
216
README.md
216
README.md
@@ -22,8 +22,8 @@ The app runs as a Next.js service and stores runtime state on disk (`./data`).
|
||||
|
||||
## Releases
|
||||
|
||||
- Latest release snapshot: [0.1.5 - Web Fetch for Direct Links](./docs/releases/0.1.5-web-fetch-direct-links.md)
|
||||
- GitHub release body : [v0.1.5](./docs/releases/github-v0.1.5.md)
|
||||
- Latest release snapshot: [0.1.6 - Telegram Long Polling](./docs/releases/0.1.6-telegram-long-polling.md)
|
||||
- GitHub release body : [v0.1.6](./docs/releases/github-v0.1.6.md)
|
||||
- Release archive: [docs/releases/README.md](./docs/releases/README.md)
|
||||
|
||||
## Contributing and Support
|
||||
@@ -34,34 +34,45 @@ The app runs as a Next.js service and stores runtime state on disk (`./data`).
|
||||
- Code of conduct: [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
|
||||
- Security policy: [SECURITY.md](./SECURITY.md)
|
||||
|
||||
## Installation (All Supported Paths)
|
||||
## Installation
|
||||
|
||||
| Path | Best for | Command |
|
||||
Choose the deployment method that best fits your needs:
|
||||
|
||||
| Method | Best For | Command |
|
||||
| --- | --- | --- |
|
||||
| One-command installer | Fastest setup, Docker-first | `curl -fsSL https://raw.githubusercontent.com/eggent-ai/eggent/main/scripts/install.sh \| bash` |
|
||||
| Local production | Run directly on your machine (Node + npm) | `npm run setup:local` |
|
||||
| Docker isolated | Containerized runtime | `npm run setup:docker` |
|
||||
| Manual setup | Full control | see [Manual Setup](#manual-setup) |
|
||||
| **Docker** (One-command) | Fastest setup, VPS, production | `curl -fsSL https://raw.githubusercontent.com/eggent-ai/eggent/main/scripts/install.sh \| bash` |
|
||||
| **Docker** (Manual) | Containerized runtime, full control | `npm run setup:docker` |
|
||||
| **Local/Node.js** | Run directly on your machine | `npm run setup:local` |
|
||||
| **Development** | Active development, hot reload | `npm run dev` |
|
||||
|
||||
## 1) One-command Installer
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Option A: One-command Installer (Recommended)
|
||||
|
||||
The fastest way to get Eggent running, especially on VPS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/eggent-ai/eggent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
What it does:
|
||||
- installs Docker (best-effort on macOS/Linux) if missing
|
||||
- clones/updates Eggent in `~/.eggent`
|
||||
- runs Docker deployment via `scripts/install-docker.sh`
|
||||
- Installs Docker (best-effort on macOS/Linux) if missing
|
||||
- Clones/updates Eggent in `~/.eggent`
|
||||
- Runs Docker deployment via `scripts/install-docker.sh`
|
||||
|
||||
Installer environment variables:
|
||||
- `EGGENT_INSTALL_DIR`: target directory (default: `~/.eggent`)
|
||||
- `EGGENT_BRANCH`: git branch (default: `main`)
|
||||
- `EGGENT_REPO_URL`: git repo URL (default: `https://github.com/eggent-ai/eggent.git`)
|
||||
- `EGGENT_AUTO_INSTALL_DOCKER`: `1`/`0` (default: `1`)
|
||||
- `EGGENT_APP_BIND_HOST`: Docker published bind host (`Linux default: 0.0.0.0`, otherwise `127.0.0.1`)
|
||||
**Environment variables:**
|
||||
|
||||
Example:
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `EGGENT_INSTALL_DIR` | `~/.eggent` | Target directory |
|
||||
| `EGGENT_BRANCH` | `main` | Git branch to use |
|
||||
| `EGGENT_REPO_URL` | `https://github.com/eggent-ai/eggent.git` | Repository URL |
|
||||
| `EGGENT_AUTO_INSTALL_DOCKER` | `1` | Auto-install Docker if missing |
|
||||
| `EGGENT_APP_BIND_HOST` | `0.0.0.0` (Linux) / `127.0.0.1` | Docker bind host |
|
||||
|
||||
Example with custom options:
|
||||
|
||||
```bash
|
||||
EGGENT_INSTALL_DIR=~/apps/eggent \
|
||||
@@ -70,24 +81,55 @@ EGGENT_AUTO_INSTALL_DOCKER=1 \
|
||||
curl -fsSL https://raw.githubusercontent.com/eggent-ai/eggent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
On Linux (including VPS installs), the one-command installer publishes app port on all interfaces by default, so app is reachable at `http://<server-ip>:3000`.
|
||||
On Linux (including VPS installs), the one-command installer publishes the app port on all interfaces by default, making it reachable at `http://<server-ip>:3000`.
|
||||
|
||||
## 2) Local Production Setup (Node + npm)
|
||||
### Option B: Manual Docker Setup
|
||||
|
||||
If you already have the repository cloned:
|
||||
|
||||
```bash
|
||||
npm run setup:docker
|
||||
```
|
||||
|
||||
This script:
|
||||
- Validates Docker + Compose
|
||||
- Prepares `.env` and `data/`
|
||||
- Builds image and starts container
|
||||
- Waits for `GET /api/health` to succeed
|
||||
|
||||
**Useful Docker commands:**
|
||||
|
||||
```bash
|
||||
docker compose logs -f app # View logs
|
||||
docker compose restart app # Restart app
|
||||
docker compose down # Stop and remove
|
||||
docker compose up -d app # Start in background
|
||||
```
|
||||
|
||||
Open: `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## Local/Node.js Deployment
|
||||
|
||||
Run Eggent directly on your machine with Node.js:
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
npm run setup:local
|
||||
```
|
||||
|
||||
This script:
|
||||
- validates Node/npm availability
|
||||
- validates `python3` availability (required for Code Execution with Python runtime)
|
||||
- validates `curl` availability (required for terminal commands like `curl ...`)
|
||||
- warns if recommended utilities are missing: `git`, `jq`, `pip3`, `rg`
|
||||
- creates `.env` from `.env.example` if needed
|
||||
- generates secure defaults for token placeholders
|
||||
- installs dependencies
|
||||
- builds production output
|
||||
- runs a health smoke-check
|
||||
- Validates Node/npm availability
|
||||
- Validates `python3` availability (required for Code Execution with Python runtime)
|
||||
- Validates `curl` availability (required for terminal commands)
|
||||
- Warns if recommended utilities are missing: `git`, `jq`, `pip3`, `rg`
|
||||
- Creates `.env` from `.env.example` if needed
|
||||
- Generates secure defaults for token placeholders
|
||||
- Installs dependencies
|
||||
- Builds production output
|
||||
- Runs a health smoke-check
|
||||
|
||||
Start the app:
|
||||
|
||||
@@ -95,54 +137,112 @@ Start the app:
|
||||
npm run start
|
||||
```
|
||||
|
||||
Open:
|
||||
- `http://localhost:3000`
|
||||
Open: `http://localhost:3000`
|
||||
|
||||
## 3) Docker Isolated Setup
|
||||
### PM2 Auto-start (Optional - Linux/macOS)
|
||||
|
||||
For production deployments with auto-restart on boot, use PM2:
|
||||
|
||||
**1. Install PM2 globally:**
|
||||
|
||||
```bash
|
||||
npm run setup:docker
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
This script:
|
||||
- validates Docker + Compose
|
||||
- prepares `.env` and `data/`
|
||||
- builds image and starts container
|
||||
- waits for `GET /api/health` to succeed
|
||||
|
||||
Open:
|
||||
- `http://localhost:3000`
|
||||
|
||||
Useful Docker commands:
|
||||
**2. Start Eggent with PM2:**
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
docker compose restart app
|
||||
docker compose down
|
||||
pm2 start npm --name eggent -- run start
|
||||
```
|
||||
|
||||
## 4) Manual Setup
|
||||
**3. Save PM2 configuration:**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# ensure python3 is installed and available in PATH
|
||||
npm install
|
||||
npm run build
|
||||
npm run start
|
||||
pm2 save
|
||||
```
|
||||
|
||||
Open:
|
||||
- `http://localhost:3000`
|
||||
**4. Setup systemd auto-start:**
|
||||
|
||||
## 5) Development Mode
|
||||
```bash
|
||||
pm2 startup systemd
|
||||
```
|
||||
|
||||
Copy and execute the command output (requires sudo).
|
||||
|
||||
**5. Verify auto-start works:**
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
# After reboot:
|
||||
pm2 status
|
||||
```
|
||||
|
||||
**Alternative: Using ecosystem file**
|
||||
|
||||
Create `ecosystem.config.js` in your Eggent directory:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "eggent",
|
||||
cwd: "/home/YOUR_USERNAME/.eggent",
|
||||
script: "npm",
|
||||
args: "run start",
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: "production"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Then start with:
|
||||
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup systemd
|
||||
```
|
||||
|
||||
**Common PM2 commands:**
|
||||
|
||||
```bash
|
||||
pm2 status # Check status
|
||||
pm2 logs eggent # View logs
|
||||
pm2 restart eggent # Restart app
|
||||
pm2 stop eggent # Stop app
|
||||
pm2 delete eggent # Remove from PM2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Mode
|
||||
|
||||
For active development with hot reload:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open:
|
||||
- `http://localhost:3000`
|
||||
Open: `http://localhost:3000`
|
||||
|
||||
### Manual Setup (Full Control)
|
||||
|
||||
If you prefer complete manual control:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Ensure python3 is installed and available in PATH
|
||||
npm install
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
Open: `http://localhost:3000`
|
||||
|
||||
## Updating Eggent
|
||||
|
||||
|
||||
46
docs/releases/0.1.6-telegram-long-polling.md
Normal file
46
docs/releases/0.1.6-telegram-long-polling.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Eggent 0.1.6 - Telegram Long Polling
|
||||
|
||||
Date: 2026-04-30
|
||||
Type: Patch release snapshot
|
||||
|
||||
## Release Name
|
||||
`Telegram Long Polling`
|
||||
|
||||
This release adds Telegram long polling alongside the existing webhook path, so Telegram integration can run without a public domain or HTTPS endpoint.
|
||||
|
||||
## What Is Included
|
||||
|
||||
### 1) Telegram Long Polling
|
||||
- Added a polling service that reads Telegram updates with `getUpdates`.
|
||||
- Added API controls for polling status, start, and stop.
|
||||
- Automatically removes an existing webhook before polling starts.
|
||||
|
||||
### 2) Shared Telegram Message Handling
|
||||
- Moved Telegram update processing into a shared message handler.
|
||||
- Kept webhook and polling modes on the same message/session/access-control behavior.
|
||||
- Preserved file upload handling and Telegram reply support.
|
||||
|
||||
### 3) Connection Mode Settings
|
||||
- Added `auto`, `webhook`, and `polling` modes for Telegram configuration.
|
||||
- Auto mode uses polling for local, private, or non-HTTPS URLs, and webhook for public HTTPS URLs.
|
||||
- Added UI controls for choosing mode and controlling polling.
|
||||
|
||||
### 4) Runtime Reliability
|
||||
- Starts Telegram lifecycle from server instrumentation instead of page layout rendering.
|
||||
- Stops polling when Telegram is disconnected.
|
||||
- Keeps webhook secret generation and mode detection consistent across runtime paths.
|
||||
|
||||
### 5) Installation Docs
|
||||
- Reworked installation docs around Docker, local Node.js, development, and PM2 deployment paths.
|
||||
|
||||
## New in 0.1.6
|
||||
|
||||
- Telegram long polling mode for installs without public HTTPS.
|
||||
- Telegram lifecycle startup via Next.js instrumentation.
|
||||
- Package/app health version bumped to `0.1.6`.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- No migration is required.
|
||||
- Existing webhook setups continue to work.
|
||||
- For local or VPS installs without HTTPS, set Telegram mode to `Auto` or `Long Polling`.
|
||||
@@ -4,6 +4,7 @@ This directory contains release summaries and publish-ready notes.
|
||||
|
||||
| Version | Name | Date | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `0.1.6` | Telegram Long Polling | 2026-04-30 | [Full snapshot](./0.1.6-telegram-long-polling.md), [GitHub body](./github-v0.1.6.md) |
|
||||
| `0.1.5` | Web Fetch for Direct Links | 2026-03-23 | [Full snapshot](./0.1.5-web-fetch-direct-links.md), [GitHub body](./github-v0.1.5.md) |
|
||||
| `0.1.4` | Web Search Autostart | 2026-03-23 | [Full snapshot](./0.1.4-web-search-autostart.md), [GitHub body](./github-v0.1.4.md) |
|
||||
| `0.1.3` | OAuth Native CLI Providers | 2026-03-06 | [Full snapshot](./0.1.3-oauth-native-cli-providers.md), [GitHub body](./github-v0.1.3.md) |
|
||||
|
||||
25
docs/releases/github-v0.1.6.md
Normal file
25
docs/releases/github-v0.1.6.md
Normal file
@@ -0,0 +1,25 @@
|
||||
## Eggent v0.1.6 - Telegram Long Polling
|
||||
|
||||
Patch release focused on making Telegram integration usable without a public HTTPS webhook.
|
||||
|
||||
### Highlights
|
||||
|
||||
- Added Telegram long polling support with status/start/stop API controls.
|
||||
- Shared Telegram update handling between webhook and polling modes.
|
||||
- Added Telegram connection modes: `auto`, `webhook`, and `polling`.
|
||||
- Auto mode now chooses polling for local, private, or non-HTTPS URLs.
|
||||
- Moved Telegram lifecycle startup into Next.js instrumentation so polling can resume on server startup.
|
||||
- Stopped polling during Telegram disconnect.
|
||||
- Updated installation documentation for Docker, local Node.js, development, and PM2 setups.
|
||||
- Version bump to `0.1.6` across package metadata and `GET /api/health`.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No migration required.
|
||||
- Existing webhook configurations continue to work.
|
||||
- Use `Auto` or `Long Polling` for installs without a public HTTPS URL.
|
||||
|
||||
### Links
|
||||
|
||||
- Full release snapshot: `docs/releases/0.1.6-telegram-long-polling.md`
|
||||
- Installation and update guide: `README.md`
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "design-vibe",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "design-vibe",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.37",
|
||||
"@ai-sdk/google": "^3.0.21",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "design-vibe",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -2,6 +2,6 @@ export async function GET() {
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "0.1.5",
|
||||
version: "0.1.6",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getTelegramIntegrationStoredSettings,
|
||||
saveTelegramIntegrationStoredSettings,
|
||||
} from "@/lib/storage/telegram-integration-store";
|
||||
import { telegramPollingService } from "@/lib/telegram/polling-service";
|
||||
|
||||
interface TelegramApiResponse {
|
||||
ok?: boolean;
|
||||
@@ -58,6 +59,10 @@ export async function POST() {
|
||||
}
|
||||
}
|
||||
|
||||
if (telegramPollingService.status.isRunning) {
|
||||
telegramPollingService.stop();
|
||||
}
|
||||
|
||||
await saveTelegramIntegrationStoredSettings({
|
||||
botToken: "",
|
||||
webhookSecret: "",
|
||||
|
||||
104
src/app/api/integrations/telegram/polling/route.ts
Normal file
104
src/app/api/integrations/telegram/polling/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
getTelegramIntegrationRuntimeConfig,
|
||||
detectTelegramMode,
|
||||
} from "@/lib/storage/telegram-integration-store";
|
||||
import { telegramPollingService } from "@/lib/telegram/polling-service";
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function GET() {
|
||||
const runtime = await getTelegramIntegrationRuntimeConfig();
|
||||
const detectedMode = detectTelegramMode(runtime);
|
||||
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
polling: telegramPollingService.status,
|
||||
config: {
|
||||
mode: runtime.mode,
|
||||
detectedMode,
|
||||
canStartPolling: !!runtime.botToken && detectedMode === "polling",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const runtime = await getTelegramIntegrationRuntimeConfig();
|
||||
const detectedMode = detectTelegramMode(runtime);
|
||||
|
||||
if (!runtime.botToken.trim()) {
|
||||
return Response.json(
|
||||
{ error: "Telegram bot token is not configured" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow polling if detected mode is polling or user explicitly forces it
|
||||
const body = (await req.json().catch(() => ({}))) as { force?: boolean };
|
||||
const force = body.force === true;
|
||||
|
||||
if (detectedMode === "webhook" && !force) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Detected mode is webhook. Use force=true to start polling anyway.",
|
||||
detectedMode,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (telegramPollingService.status.isRunning) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Polling is already running",
|
||||
polling: telegramPollingService.status,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await telegramPollingService.start(runtime);
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
message: "Polling started",
|
||||
polling: telegramPollingService.status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Telegram Polling API] Error starting polling:", error);
|
||||
return Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to start polling",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
if (!telegramPollingService.status.isRunning) {
|
||||
return Response.json(
|
||||
{ error: "Polling is not running" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
telegramPollingService.stop();
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
message: "Polling stopped",
|
||||
polling: telegramPollingService.status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Telegram Polling API] Error stopping polling:", error);
|
||||
return Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to stop polling",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +1,12 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
ExternalMessageError,
|
||||
handleExternalMessage,
|
||||
} from "@/lib/external/handle-external-message";
|
||||
import {
|
||||
createDefaultTelegramSessionId,
|
||||
createFreshTelegramSessionId,
|
||||
getTelegramChatSessionId,
|
||||
setTelegramChatSessionId,
|
||||
} from "@/lib/storage/telegram-session-store";
|
||||
import {
|
||||
claimTelegramUpdate,
|
||||
releaseTelegramUpdate,
|
||||
} from "@/lib/storage/telegram-update-store";
|
||||
import {
|
||||
consumeTelegramAccessCode,
|
||||
getTelegramIntegrationRuntimeConfig,
|
||||
normalizeTelegramUserId,
|
||||
} from "@/lib/storage/telegram-integration-store";
|
||||
import { saveChatFile } from "@/lib/storage/chat-files-store";
|
||||
import { createChat, getChat } from "@/lib/storage/chat-store";
|
||||
import {
|
||||
contextKey,
|
||||
type ExternalSession,
|
||||
getOrCreateExternalSession,
|
||||
saveExternalSession,
|
||||
} from "@/lib/storage/external-session-store";
|
||||
import { getAllProjects } from "@/lib/storage/project-store";
|
||||
|
||||
const TELEGRAM_TEXT_LIMIT = 4096;
|
||||
const TELEGRAM_FILE_MAX_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id?: unknown;
|
||||
message?: TelegramMessage;
|
||||
}
|
||||
|
||||
interface TelegramMessage {
|
||||
message_id?: unknown;
|
||||
text?: unknown;
|
||||
caption?: unknown;
|
||||
from?: {
|
||||
id?: unknown;
|
||||
};
|
||||
chat?: {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
};
|
||||
document?: {
|
||||
file_id?: unknown;
|
||||
file_name?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
photo?: Array<{
|
||||
file_id?: unknown;
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
}>;
|
||||
audio?: {
|
||||
file_id?: unknown;
|
||||
file_name?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
video?: {
|
||||
file_id?: unknown;
|
||||
file_name?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
voice?: {
|
||||
file_id?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface TelegramApiResponse {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TelegramIncomingFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface TelegramExternalChatContext {
|
||||
chatId: string;
|
||||
projectId?: string;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
function normalizeTelegramCurrentPath(rawPath: string | undefined): string {
|
||||
const value = (rawPath ?? "").trim();
|
||||
if (!value || value === "/telegram") {
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
interface TelegramResolvedProjectContext {
|
||||
session: ExternalSession;
|
||||
resolvedProjectId?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function parseTelegramError(status: number, payload: TelegramApiResponse | null): string {
|
||||
const description = payload?.description?.trim();
|
||||
return description
|
||||
? `Telegram API error (${status}): ${description}`
|
||||
: `Telegram API error (${status})`;
|
||||
}
|
||||
|
||||
async function callTelegramApi(
|
||||
botToken: string,
|
||||
method: string,
|
||||
body?: Record<string, unknown>
|
||||
): Promise<TelegramApiResponse> {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
|
||||
method: body ? "POST" : "GET",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| TelegramApiResponse
|
||||
| null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(parseTelegramError(response.status, payload));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
processTelegramUpdate,
|
||||
type TelegramUpdate,
|
||||
} from "@/lib/telegram/telegram-message-handler";
|
||||
|
||||
function safeTokenMatch(actual: string, expected: string): boolean {
|
||||
const actualBytes = Buffer.from(actual);
|
||||
@@ -142,334 +18,6 @@ function safeTokenMatch(actual: string, expected: string): boolean {
|
||||
return timingSafeEqual(actualBytes, expectedBytes);
|
||||
}
|
||||
|
||||
function getBotId(botToken: string): string {
|
||||
const [rawBotId] = botToken.trim().split(":", 1);
|
||||
const botId = rawBotId?.trim() || "default";
|
||||
return botId.replace(/[^a-zA-Z0-9._:-]/g, "_").slice(0, 128) || "default";
|
||||
}
|
||||
|
||||
function chatBelongsToProject(
|
||||
chatProjectId: string | undefined,
|
||||
projectId: string | undefined
|
||||
): boolean {
|
||||
const left = chatProjectId ?? null;
|
||||
const right = projectId ?? null;
|
||||
return left === right;
|
||||
}
|
||||
|
||||
async function ensureTelegramExternalChatContext(params: {
|
||||
sessionId: string;
|
||||
defaultProjectId?: string;
|
||||
}): Promise<TelegramExternalChatContext> {
|
||||
const { session, resolvedProjectId } = await resolveTelegramProjectContext({
|
||||
sessionId: params.sessionId,
|
||||
defaultProjectId: params.defaultProjectId,
|
||||
});
|
||||
const projectKey = contextKey(resolvedProjectId);
|
||||
let resolvedChatId = session.activeChats[projectKey];
|
||||
if (resolvedChatId) {
|
||||
const existing = await getChat(resolvedChatId);
|
||||
if (!existing || !chatBelongsToProject(existing.projectId, resolvedProjectId)) {
|
||||
resolvedChatId = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedChatId) {
|
||||
resolvedChatId = crypto.randomUUID();
|
||||
await createChat(
|
||||
resolvedChatId,
|
||||
`External session ${session.id}`,
|
||||
resolvedProjectId
|
||||
);
|
||||
}
|
||||
|
||||
session.activeChats[projectKey] = resolvedChatId;
|
||||
session.currentPaths[projectKey] = normalizeTelegramCurrentPath(
|
||||
session.currentPaths[projectKey]
|
||||
);
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await saveExternalSession(session);
|
||||
|
||||
return {
|
||||
chatId: resolvedChatId,
|
||||
projectId: resolvedProjectId,
|
||||
currentPath: session.currentPaths[projectKey] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTelegramProjectContext(params: {
|
||||
sessionId: string;
|
||||
defaultProjectId?: string;
|
||||
}): Promise<TelegramResolvedProjectContext> {
|
||||
const session = await getOrCreateExternalSession(params.sessionId);
|
||||
const projects = await getAllProjects();
|
||||
const projectById = new Map(projects.map((project) => [project.id, project]));
|
||||
|
||||
let resolvedProjectId: string | undefined;
|
||||
const explicitProjectId = params.defaultProjectId?.trim() || "";
|
||||
if (explicitProjectId) {
|
||||
if (!projectById.has(explicitProjectId)) {
|
||||
throw new Error(`Project "${explicitProjectId}" not found`);
|
||||
}
|
||||
resolvedProjectId = explicitProjectId;
|
||||
session.activeProjectId = explicitProjectId;
|
||||
} else if (session.activeProjectId && projectById.has(session.activeProjectId)) {
|
||||
resolvedProjectId = session.activeProjectId;
|
||||
} else if (projects.length > 0) {
|
||||
resolvedProjectId = projects[0].id;
|
||||
session.activeProjectId = projects[0].id;
|
||||
} else {
|
||||
session.activeProjectId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
resolvedProjectId,
|
||||
projectName: resolvedProjectId ? projectById.get(resolvedProjectId)?.name : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extensionFromMime(mimeType: string): string {
|
||||
const lower = mimeType.toLowerCase();
|
||||
if (lower.includes("pdf")) return ".pdf";
|
||||
if (lower.includes("png")) return ".png";
|
||||
if (lower.includes("jpeg") || lower.includes("jpg")) return ".jpg";
|
||||
if (lower.includes("webp")) return ".webp";
|
||||
if (lower.includes("gif")) return ".gif";
|
||||
if (lower.includes("mp4")) return ".mp4";
|
||||
if (lower.includes("mpeg") || lower.includes("mp3")) return ".mp3";
|
||||
if (lower.includes("ogg")) return ".ogg";
|
||||
if (lower.includes("wav")) return ".wav";
|
||||
if (lower.includes("plain")) return ".txt";
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildIncomingFileName(params: {
|
||||
base: string;
|
||||
messageId?: number;
|
||||
mimeType?: string;
|
||||
}): string {
|
||||
const suffix = params.messageId ?? Date.now();
|
||||
const ext = params.mimeType ? extensionFromMime(params.mimeType) : "";
|
||||
return `${params.base}-${suffix}${ext}`;
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
const base = value.trim().replace(/[\\/]+/g, "_");
|
||||
return base || `file-${Date.now()}`;
|
||||
}
|
||||
|
||||
function withMessageIdPrefix(fileName: string, messageId?: number): string {
|
||||
if (typeof messageId !== "number") return fileName;
|
||||
return `${messageId}-${fileName}`;
|
||||
}
|
||||
|
||||
function extractIncomingFile(
|
||||
message: TelegramMessage,
|
||||
messageId?: number
|
||||
): TelegramIncomingFile | null {
|
||||
const documentFileId =
|
||||
typeof message.document?.file_id === "string"
|
||||
? message.document.file_id.trim()
|
||||
: "";
|
||||
if (documentFileId) {
|
||||
const docNameRaw =
|
||||
typeof message.document?.file_name === "string"
|
||||
? message.document.file_name
|
||||
: "";
|
||||
const fallback = buildIncomingFileName({
|
||||
base: "document",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.document?.mime_type === "string"
|
||||
? message.document.mime_type
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
fileId: documentFileId,
|
||||
fileName: withMessageIdPrefix(sanitizeFileName(docNameRaw || fallback), messageId),
|
||||
};
|
||||
}
|
||||
|
||||
const photos: Array<{ file_id?: unknown }> = Array.isArray(message.photo)
|
||||
? message.photo
|
||||
: [];
|
||||
for (let i = photos.length - 1; i >= 0; i -= 1) {
|
||||
const photo = photos[i];
|
||||
const fileId = typeof photo?.file_id === "string" ? photo.file_id.trim() : "";
|
||||
if (fileId) {
|
||||
return {
|
||||
fileId,
|
||||
fileName: sanitizeFileName(
|
||||
buildIncomingFileName({ base: "photo", messageId, mimeType: "image/jpeg" })
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const audioFileId =
|
||||
typeof message.audio?.file_id === "string" ? message.audio.file_id.trim() : "";
|
||||
if (audioFileId) {
|
||||
const audioNameRaw =
|
||||
typeof message.audio?.file_name === "string" ? message.audio.file_name : "";
|
||||
const fallback = buildIncomingFileName({
|
||||
base: "audio",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.audio?.mime_type === "string"
|
||||
? message.audio.mime_type
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
fileId: audioFileId,
|
||||
fileName: withMessageIdPrefix(sanitizeFileName(audioNameRaw || fallback), messageId),
|
||||
};
|
||||
}
|
||||
|
||||
const videoFileId =
|
||||
typeof message.video?.file_id === "string" ? message.video.file_id.trim() : "";
|
||||
if (videoFileId) {
|
||||
const videoNameRaw =
|
||||
typeof message.video?.file_name === "string" ? message.video.file_name : "";
|
||||
const fallback = buildIncomingFileName({
|
||||
base: "video",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.video?.mime_type === "string"
|
||||
? message.video.mime_type
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
fileId: videoFileId,
|
||||
fileName: withMessageIdPrefix(sanitizeFileName(videoNameRaw || fallback), messageId),
|
||||
};
|
||||
}
|
||||
|
||||
const voiceFileId =
|
||||
typeof message.voice?.file_id === "string" ? message.voice.file_id.trim() : "";
|
||||
if (voiceFileId) {
|
||||
return {
|
||||
fileId: voiceFileId,
|
||||
fileName: sanitizeFileName(
|
||||
buildIncomingFileName({
|
||||
base: "voice",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.voice?.mime_type === "string"
|
||||
? message.voice.mime_type
|
||||
: undefined,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadTelegramFile(botToken: string, fileId: string): Promise<Buffer> {
|
||||
const payload = await callTelegramApi(botToken, "getFile", {
|
||||
file_id: fileId,
|
||||
});
|
||||
const result = payload.result ?? {};
|
||||
const filePath = typeof result.file_path === "string" ? result.file_path : "";
|
||||
if (!filePath) {
|
||||
throw new Error("Telegram getFile returned empty file_path");
|
||||
}
|
||||
|
||||
const fileUrl = `https://api.telegram.org/file/bot${botToken}/${filePath}`;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download Telegram file (${response.status})`);
|
||||
}
|
||||
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength > TELEGRAM_FILE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Telegram file is too large (${bytes.byteLength} bytes). Max supported size is ${TELEGRAM_FILE_MAX_BYTES} bytes.`
|
||||
);
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function extractCommand(text: string): string | null {
|
||||
const first = text.trim().split(/\s+/, 1)[0];
|
||||
if (!first || !first.startsWith("/")) return null;
|
||||
return first.split("@", 1)[0].toLowerCase();
|
||||
}
|
||||
|
||||
function extractAccessCodeCandidate(text: string): string | null {
|
||||
const value = text.trim();
|
||||
if (!value) return null;
|
||||
|
||||
const fromCommand = value.match(
|
||||
/^\/(?:code|start)(?:@[a-zA-Z0-9_]+)?\s+([A-Za-z0-9_-]{6,64})$/i
|
||||
);
|
||||
if (fromCommand?.[1]) {
|
||||
return fromCommand[1];
|
||||
}
|
||||
|
||||
if (/^[A-Za-z0-9_-]{6,64}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeOutgoingText(text: string): string {
|
||||
const value = text.trim();
|
||||
if (!value) return "Пустой ответ от агента.";
|
||||
if (value.length <= TELEGRAM_TEXT_LIMIT) return value;
|
||||
return `${value.slice(0, TELEGRAM_TEXT_LIMIT - 1)}…`;
|
||||
}
|
||||
|
||||
async function sendTelegramMessage(
|
||||
botToken: string,
|
||||
chatId: number | string,
|
||||
text: string,
|
||||
replyToMessageId?: number
|
||||
): Promise<void> {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: normalizeOutgoingText(text),
|
||||
...(typeof replyToMessageId === "number" ? { reply_to_message_id: replyToMessageId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { ok?: boolean; description?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(
|
||||
`Telegram sendMessage failed (${response.status})${payload?.description ? `: ${payload.description}` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function helpText(activeProject?: { id?: string; name?: string }): string {
|
||||
const activeProjectLine = activeProject?.id
|
||||
? `Active project: ${activeProject.name ? `${activeProject.name} (${activeProject.id})` : activeProject.id}`
|
||||
: "Active project: not selected";
|
||||
return [
|
||||
"Telegram connection is active.",
|
||||
activeProjectLine,
|
||||
"",
|
||||
"Commands:",
|
||||
"/start - show this help",
|
||||
"/help - show this help",
|
||||
"/code <access_code> - activate access for your Telegram user",
|
||||
"/new - start a new conversation (reset context)",
|
||||
"",
|
||||
"Text messages are sent to the agent.",
|
||||
"File uploads are saved into chat files.",
|
||||
"You can also ask the agent to send a local file back to Telegram.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function GET() {
|
||||
@@ -484,8 +32,6 @@ export async function POST(req: NextRequest) {
|
||||
const runtime = await getTelegramIntegrationRuntimeConfig();
|
||||
const botToken = runtime.botToken.trim();
|
||||
const webhookSecret = runtime.webhookSecret.trim();
|
||||
const defaultProjectId = runtime.defaultProjectId || undefined;
|
||||
const allowedUserIds = new Set(runtime.allowedUserIds);
|
||||
|
||||
if (!botToken || !webhookSecret) {
|
||||
return Response.json(
|
||||
@@ -502,235 +48,11 @@ export async function POST(req: NextRequest) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let botIdForRollback: string | null = null;
|
||||
let updateIdForRollback: number | null = null;
|
||||
|
||||
try {
|
||||
const body = (await req.json()) as TelegramUpdate;
|
||||
const updateId =
|
||||
typeof body.update_id === "number" && Number.isInteger(body.update_id)
|
||||
? body.update_id
|
||||
: null;
|
||||
if (updateId === null) {
|
||||
return Response.json({ error: "Invalid update_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const botId = getBotId(botToken);
|
||||
botIdForRollback = botId;
|
||||
updateIdForRollback = updateId;
|
||||
const isNewUpdate = await claimTelegramUpdate(botId, updateId);
|
||||
if (!isNewUpdate) {
|
||||
return Response.json({ ok: true, duplicate: true });
|
||||
}
|
||||
|
||||
const message = body.message;
|
||||
const chatId =
|
||||
typeof message?.chat?.id === "number" || typeof message?.chat?.id === "string"
|
||||
? message.chat.id
|
||||
: null;
|
||||
const chatType = typeof message?.chat?.type === "string" ? message.chat.type : "";
|
||||
const messageId =
|
||||
typeof message?.message_id === "number" ? message.message_id : undefined;
|
||||
|
||||
if (chatId === null || !chatType) {
|
||||
return Response.json({ ok: true, ignored: true, reason: "unsupported_update" });
|
||||
}
|
||||
|
||||
if (chatType !== "private") {
|
||||
return Response.json({ ok: true, ignored: true, reason: "private_only" });
|
||||
}
|
||||
|
||||
const text = typeof message?.text === "string" ? message.text.trim() : "";
|
||||
const caption =
|
||||
typeof message?.caption === "string" ? message.caption.trim() : "";
|
||||
const incomingText = text || caption;
|
||||
const fromUserId = normalizeTelegramUserId(message?.from?.id);
|
||||
|
||||
if (!fromUserId) {
|
||||
return Response.json({
|
||||
ok: true,
|
||||
ignored: true,
|
||||
reason: "missing_user_id",
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowedUserIds.has(fromUserId)) {
|
||||
const accessCode = extractAccessCodeCandidate(text);
|
||||
const granted =
|
||||
accessCode &&
|
||||
(await consumeTelegramAccessCode({
|
||||
code: accessCode,
|
||||
userId: fromUserId,
|
||||
}));
|
||||
|
||||
if (granted) {
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Доступ выдан. Теперь можно отправлять сообщения агенту.",
|
||||
messageId
|
||||
);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
accessGranted: true,
|
||||
userId: fromUserId,
|
||||
});
|
||||
}
|
||||
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
[
|
||||
"Доступ запрещён: ваш user_id не в списке разрешённых.",
|
||||
"Отправьте код активации командой /code <код> или /start <код>.",
|
||||
`Ваш user_id: ${fromUserId}`,
|
||||
].join("\n"),
|
||||
messageId
|
||||
);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
ignored: true,
|
||||
reason: "user_not_allowed",
|
||||
userId: fromUserId,
|
||||
});
|
||||
}
|
||||
|
||||
let sessionId = await getTelegramChatSessionId(botId, chatId);
|
||||
if (!sessionId) {
|
||||
sessionId = createDefaultTelegramSessionId(botId, chatId);
|
||||
await setTelegramChatSessionId(botId, chatId, sessionId);
|
||||
}
|
||||
|
||||
const command = extractCommand(text);
|
||||
if (command === "/start" || command === "/help") {
|
||||
const resolvedProject = await resolveTelegramProjectContext({
|
||||
sessionId,
|
||||
defaultProjectId,
|
||||
});
|
||||
await saveExternalSession({
|
||||
...resolvedProject.session,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
helpText({
|
||||
id: resolvedProject.resolvedProjectId,
|
||||
name: resolvedProject.projectName,
|
||||
}),
|
||||
messageId
|
||||
);
|
||||
return Response.json({ ok: true, command });
|
||||
}
|
||||
|
||||
if (command === "/new") {
|
||||
const freshSessionId = createFreshTelegramSessionId(botId, chatId);
|
||||
await setTelegramChatSessionId(botId, chatId, freshSessionId);
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Начал новый диалог. Контекст очищен для следующего сообщения.",
|
||||
messageId
|
||||
);
|
||||
return Response.json({ ok: true, command });
|
||||
}
|
||||
|
||||
let incomingSavedFile:
|
||||
| {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
const incomingFile = message ? extractIncomingFile(message, messageId) : null;
|
||||
let externalContext: TelegramExternalChatContext | null = null;
|
||||
if (incomingFile) {
|
||||
externalContext = await ensureTelegramExternalChatContext({
|
||||
sessionId,
|
||||
defaultProjectId,
|
||||
});
|
||||
const fileBuffer = await downloadTelegramFile(botToken, incomingFile.fileId);
|
||||
const saved = await saveChatFile(
|
||||
externalContext.chatId,
|
||||
fileBuffer,
|
||||
incomingFile.fileName
|
||||
);
|
||||
incomingSavedFile = {
|
||||
name: saved.name,
|
||||
path: saved.path,
|
||||
size: saved.size,
|
||||
};
|
||||
}
|
||||
|
||||
if (!incomingText) {
|
||||
if (incomingSavedFile) {
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
`File "${incomingSavedFile.name}" saved to chat files.`,
|
||||
messageId
|
||||
);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
fileSaved: true,
|
||||
file: incomingSavedFile,
|
||||
});
|
||||
}
|
||||
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Only text messages and file uploads are supported right now.",
|
||||
messageId
|
||||
);
|
||||
return Response.json({ ok: true, ignored: true, reason: "non_text" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleExternalMessage({
|
||||
sessionId,
|
||||
message: incomingSavedFile
|
||||
? `${incomingText}\n\nAttached file: ${incomingSavedFile.name}`
|
||||
: incomingText,
|
||||
projectId: externalContext?.projectId ?? defaultProjectId,
|
||||
chatId: externalContext?.chatId,
|
||||
currentPath: normalizeTelegramCurrentPath(externalContext?.currentPath),
|
||||
runtimeData: {
|
||||
telegram: {
|
||||
botToken,
|
||||
chatId,
|
||||
replyToMessageId: messageId ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await sendTelegramMessage(botToken, chatId, result.reply, messageId);
|
||||
return Response.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalMessageError) {
|
||||
const errorMessage =
|
||||
typeof error.payload.error === "string"
|
||||
? error.payload.error
|
||||
: "Не удалось обработать сообщение.";
|
||||
await sendTelegramMessage(botToken, chatId, `Ошибка: ${errorMessage}`, messageId);
|
||||
return Response.json({ ok: true, handledError: true, status: error.status });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await processTelegramUpdate(body, runtime);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
if (
|
||||
botIdForRollback &&
|
||||
typeof updateIdForRollback === "number" &&
|
||||
Number.isInteger(updateIdForRollback)
|
||||
) {
|
||||
try {
|
||||
await releaseTelegramUpdate(botIdForRollback, updateIdForRollback);
|
||||
} catch (releaseError) {
|
||||
console.error("Telegram rollback error:", releaseError);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Telegram webhook error:", error);
|
||||
return Response.json(
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function ApiPage() {
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<h3 className="text-lg font-medium">Telegram Webhook</h3>
|
||||
<h3 className="text-lg font-medium">Telegram Integration</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Telegram endpoint: <span className="font-mono">POST /api/integrations/telegram</span>.
|
||||
It reuses the same external session context engine as{" "}
|
||||
@@ -64,14 +64,45 @@ export default function ApiPage() {
|
||||
Configure credentials in <span className="font-mono">Dashboard -> Messengers</span>
|
||||
(bot token is enough; webhook secret/url are configured automatically).
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \\
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Connection Modes</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-sm text-muted-foreground">
|
||||
<li><strong>Webhook</strong> (default for public HTTPS URLs): Telegram pushes updates to your server. Requires a public HTTPS URL.</li>
|
||||
<li><strong>Long Polling</strong> (default for localhost): Your server periodically fetches updates from Telegram. Works without HTTPS, perfect for local development.</li>
|
||||
<li><strong>Auto</strong> (recommended): Automatically selects the best mode based on your Public Base URL configuration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Webhook Setup (Production)</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"url": "https://YOUR_PUBLIC_BASE_URL/api/integrations/telegram",
|
||||
"secret_token": "'$TELEGRAM_WEBHOOK_SECRET'"
|
||||
}'`}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Long Polling API (Development)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When using long polling mode, control the polling service via API:
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`# Get polling status
|
||||
GET /api/integrations/telegram/polling
|
||||
|
||||
# Start polling
|
||||
POST /api/integrations/telegram/polling
|
||||
|
||||
# Stop polling
|
||||
DELETE /api/integrations/telegram/polling`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Supported commands: <span className="font-mono">/start</span>,{" "}
|
||||
<span className="font-mono">/help</span>,{" "}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { KeyRound, Loader2, Link2, RotateCcw, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Loader2, Link2, ShieldCheck, Trash2, Play, Square, Radio, Globe } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -14,9 +14,13 @@ interface TelegramSettingsResponse {
|
||||
allowedUserIds: string[];
|
||||
pendingAccessCodes: number;
|
||||
updatedAt: string | null;
|
||||
mode: "auto" | "webhook" | "polling";
|
||||
pollingInterval: number;
|
||||
detectedMode: "webhook" | "polling";
|
||||
sources: {
|
||||
botToken: "stored" | "env" | "none";
|
||||
webhookSecret: "stored" | "env" | "none";
|
||||
mode: "stored" | "env" | "none";
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
@@ -29,19 +33,24 @@ interface TelegramAccessCodeResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WebhookStatusResponse {
|
||||
configured: boolean;
|
||||
message?: string;
|
||||
webhook: {
|
||||
url: string;
|
||||
pendingUpdateCount: number;
|
||||
lastErrorDate: number | null;
|
||||
lastErrorMessage: string | null;
|
||||
} | null;
|
||||
error?: string;
|
||||
interface PollingStatusResponse {
|
||||
status: string;
|
||||
polling: {
|
||||
isRunning: boolean;
|
||||
lastUpdateId: number | null;
|
||||
lastPollTime: string | null;
|
||||
errorCount: number;
|
||||
consecutiveErrors: number;
|
||||
};
|
||||
config: {
|
||||
mode: "auto" | "webhook" | "polling";
|
||||
detectedMode: "webhook" | "polling";
|
||||
canStartPolling: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type ActionState = "idle" | "loading";
|
||||
type TelegramMode = "auto" | "webhook" | "polling";
|
||||
|
||||
function sourceLabel(source: "stored" | "env" | "none"): string {
|
||||
if (source === "stored") return "stored in app";
|
||||
@@ -56,6 +65,48 @@ export function TelegramIntegrationManager() {
|
||||
const [tokenSource, setTokenSource] = useState<"stored" | "env" | "none">(
|
||||
"none"
|
||||
);
|
||||
const [mode, setMode] = useState<TelegramMode>("auto");
|
||||
const [detectedMode, setDetectedMode] = useState<"webhook" | "polling">("polling");
|
||||
|
||||
// Helper to detect if URL is localhost/private (needs polling) or public (can use webhook)
|
||||
const detectUrlMode = useCallback((url: string): "webhook" | "polling" => {
|
||||
const normalized = url.trim();
|
||||
if (!normalized) return "polling";
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (parsed.protocol !== "https:") return "polling";
|
||||
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "::1" ||
|
||||
hostname.endsWith(".local")
|
||||
) {
|
||||
return "polling";
|
||||
}
|
||||
|
||||
const octets = hostname.split(".").map((part) => Number(part));
|
||||
if (
|
||||
octets.length === 4 &&
|
||||
octets.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)
|
||||
) {
|
||||
const [first, second] = octets;
|
||||
if (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 192 && second === 168) ||
|
||||
(first === 172 && second >= 16 && second <= 31)
|
||||
) {
|
||||
return "polling";
|
||||
}
|
||||
}
|
||||
|
||||
return "webhook";
|
||||
} catch {
|
||||
return "polling";
|
||||
}
|
||||
}, []);
|
||||
const [allowedUserIdsInput, setAllowedUserIdsInput] = useState("");
|
||||
const [pendingAccessCodes, setPendingAccessCodes] = useState(0);
|
||||
const [generatedAccessCode, setGeneratedAccessCode] = useState<string | null>(null);
|
||||
@@ -63,16 +114,14 @@ export function TelegramIntegrationManager() {
|
||||
string | null
|
||||
>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||
const [webhookStatus, setWebhookStatus] = useState<WebhookStatusResponse | null>(
|
||||
null
|
||||
);
|
||||
const [pollingStatus, setPollingStatus] = useState<PollingStatusResponse | null>(null);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [connectState, setConnectState] = useState<ActionState>("idle");
|
||||
const [reconnectState, setReconnectState] = useState<ActionState>("idle");
|
||||
const [disconnectState, setDisconnectState] = useState<ActionState>("idle");
|
||||
const [saveAllowedUsersState, setSaveAllowedUsersState] = useState<ActionState>("idle");
|
||||
const [generateCodeState, setGenerateCodeState] = useState<ActionState>("idle");
|
||||
const [webhookState, setWebhookState] = useState<ActionState>("idle");
|
||||
const [pollingState, setPollingState] = useState<ActionState>("idle");
|
||||
const [modeState, setModeState] = useState<ActionState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
@@ -90,6 +139,8 @@ export function TelegramIntegrationManager() {
|
||||
setStoredMaskedToken(data.botToken || "");
|
||||
setPublicBaseUrl(data.publicBaseUrl || "");
|
||||
setTokenSource(data.sources.botToken);
|
||||
setMode(data.mode || "auto");
|
||||
setDetectedMode(data.detectedMode || "polling");
|
||||
setAllowedUserIdsInput((data.allowedUserIds || []).join(", "));
|
||||
setPendingAccessCodes(
|
||||
typeof data.pendingAccessCodes === "number" ? data.pendingAccessCodes : 0
|
||||
@@ -102,28 +153,37 @@ export function TelegramIntegrationManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadWebhookStatus = useCallback(async () => {
|
||||
setWebhookState("loading");
|
||||
const loadPollingStatus = useCallback(async () => {
|
||||
setPollingState("loading");
|
||||
try {
|
||||
const res = await fetch("/api/integrations/telegram/webhook", {
|
||||
const res = await fetch("/api/integrations/telegram/polling", {
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = (await res.json()) as WebhookStatusResponse;
|
||||
const data = (await res.json()) as PollingStatusResponse;
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to load webhook status");
|
||||
throw new Error("Failed to load polling status");
|
||||
}
|
||||
setWebhookStatus(data);
|
||||
setPollingStatus(data);
|
||||
} catch {
|
||||
setWebhookStatus(null);
|
||||
setPollingStatus(null);
|
||||
} finally {
|
||||
setWebhookState("idle");
|
||||
setPollingState("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadWebhookStatus();
|
||||
}, [loadSettings, loadWebhookStatus]);
|
||||
loadPollingStatus();
|
||||
|
||||
// Refresh polling status every 5 seconds when in polling mode
|
||||
const interval = setInterval(() => {
|
||||
if (detectedMode === "polling" || mode === "polling") {
|
||||
loadPollingStatus();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSettings, loadPollingStatus, detectedMode, mode]);
|
||||
|
||||
const connectTelegram = useCallback(async () => {
|
||||
setConnectState("loading");
|
||||
@@ -133,9 +193,6 @@ export function TelegramIntegrationManager() {
|
||||
const trimmedToken = botToken.trim();
|
||||
const trimmedBaseUrl = publicBaseUrl.trim();
|
||||
|
||||
if (!trimmedBaseUrl) {
|
||||
throw new Error("Public Base URL is required");
|
||||
}
|
||||
if (!trimmedToken && tokenSource === "none") {
|
||||
throw new Error("Telegram bot token is required");
|
||||
}
|
||||
@@ -169,43 +226,15 @@ export function TelegramIntegrationManager() {
|
||||
throw new Error(setupData.error || "Failed to connect Telegram");
|
||||
}
|
||||
|
||||
setSuccess(setupData.message || "Telegram connected");
|
||||
setSuccess(setupData.message || "Webhook configured");
|
||||
setBotToken("");
|
||||
await Promise.all([loadSettings(), loadWebhookStatus()]);
|
||||
await loadSettings();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to connect Telegram");
|
||||
} finally {
|
||||
setConnectState("idle");
|
||||
}
|
||||
}, [botToken, loadSettings, loadWebhookStatus, publicBaseUrl, tokenSource]);
|
||||
|
||||
const reconnectTelegram = useCallback(async () => {
|
||||
setReconnectState("loading");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/telegram/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to reconnect Telegram");
|
||||
}
|
||||
|
||||
setSuccess(data.message || "Telegram reconnected");
|
||||
await Promise.all([loadSettings(), loadWebhookStatus()]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to reconnect Telegram");
|
||||
} finally {
|
||||
setReconnectState("idle");
|
||||
}
|
||||
}, [loadSettings, loadWebhookStatus]);
|
||||
}, [botToken, loadSettings, publicBaseUrl, tokenSource]);
|
||||
|
||||
const disconnectTelegram = useCallback(async () => {
|
||||
setDisconnectState("loading");
|
||||
@@ -231,13 +260,13 @@ export function TelegramIntegrationManager() {
|
||||
setSuccess(messages.join(" "));
|
||||
|
||||
setBotToken("");
|
||||
await Promise.all([loadSettings(), loadWebhookStatus()]);
|
||||
await loadSettings();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to disconnect Telegram");
|
||||
} finally {
|
||||
setDisconnectState("idle");
|
||||
}
|
||||
}, [loadSettings, loadWebhookStatus]);
|
||||
}, [loadSettings]);
|
||||
|
||||
const saveAllowedUsers = useCallback(async () => {
|
||||
setSaveAllowedUsersState("loading");
|
||||
@@ -295,23 +324,84 @@ export function TelegramIntegrationManager() {
|
||||
}
|
||||
}, [loadSettings]);
|
||||
|
||||
const hasTokenConfigured = tokenSource !== "none";
|
||||
const hasBaseUrlConfigured = publicBaseUrl.trim().length > 0;
|
||||
const isConnected = hasTokenConfigured && hasBaseUrlConfigured;
|
||||
const startPolling = useCallback(async () => {
|
||||
setPollingState("loading");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/telegram/polling", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = (await res.json()) as { ok?: boolean; message?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to start polling");
|
||||
}
|
||||
setSuccess(data.message || "Polling started");
|
||||
await loadPollingStatus();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to start polling");
|
||||
} finally {
|
||||
setPollingState("idle");
|
||||
}
|
||||
}, [loadPollingStatus]);
|
||||
|
||||
const canConnect = useMemo(() => {
|
||||
if (!publicBaseUrl.trim()) return false;
|
||||
if (botToken.trim()) return true;
|
||||
return tokenSource !== "none";
|
||||
}, [botToken, publicBaseUrl, tokenSource]);
|
||||
const stopPolling = useCallback(async () => {
|
||||
setPollingState("loading");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/telegram/polling", {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = (await res.json()) as { ok?: boolean; message?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to stop polling");
|
||||
}
|
||||
setSuccess(data.message || "Polling stopped");
|
||||
await loadPollingStatus();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to stop polling");
|
||||
} finally {
|
||||
setPollingState("idle");
|
||||
}
|
||||
}, [loadPollingStatus]);
|
||||
|
||||
const saveMode = useCallback(async (newMode: TelegramMode) => {
|
||||
setModeState("loading");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/telegram/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: newMode }),
|
||||
});
|
||||
const data = (await res.json()) as TelegramSettingsResponse;
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to save mode");
|
||||
}
|
||||
setMode(data.mode || "auto");
|
||||
setDetectedMode(data.detectedMode || "polling");
|
||||
setSuccess(`Mode updated to ${newMode}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save mode");
|
||||
} finally {
|
||||
setModeState("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasTokenConfigured = tokenSource !== "none";
|
||||
|
||||
const isBusy =
|
||||
loadingSettings ||
|
||||
connectState === "loading" ||
|
||||
reconnectState === "loading" ||
|
||||
disconnectState === "loading" ||
|
||||
saveAllowedUsersState === "loading" ||
|
||||
generateCodeState === "loading";
|
||||
generateCodeState === "loading" ||
|
||||
pollingState === "loading" ||
|
||||
modeState === "loading";
|
||||
|
||||
const updatedAtLabel = useMemo(() => {
|
||||
if (!updatedAt) return null;
|
||||
@@ -320,127 +410,311 @@ export function TelegramIntegrationManager() {
|
||||
return date.toLocaleString();
|
||||
}, [updatedAt]);
|
||||
|
||||
// Determine effective mode considering auto detection
|
||||
const effectiveMode = mode === "auto" ? detectedMode : mode;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Step 1: Bot Token */}
|
||||
<section className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium">Telegram</h3>
|
||||
{!isConnected ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the bot token and Public Base URL, then click Connect Telegram.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Telegram is connected. You can reconnect or disconnect it.
|
||||
<h3 className="text-lg font-medium">1. Bot Token</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your Telegram bot token from @BotFather.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telegram-bot-token">Bot Token</Label>
|
||||
<Input
|
||||
id="telegram-bot-token"
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
placeholder="123456789:AA..."
|
||||
disabled={isBusy || hasTokenConfigured}
|
||||
/>
|
||||
{hasTokenConfigured && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Token saved ({sourceLabel(tokenSource)})
|
||||
{storedMaskedToken ? `: ${storedMaskedToken}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isConnected ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telegram-bot-token">Bot Token</Label>
|
||||
<Input
|
||||
id="telegram-bot-token"
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
placeholder="123456789:AA..."
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current source: {sourceLabel(tokenSource)}
|
||||
{storedMaskedToken ? ` (${storedMaskedToken})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{!hasTokenConfigured && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const trimmedToken = botToken.trim();
|
||||
if (!trimmedToken) {
|
||||
setError("Bot token is required");
|
||||
return;
|
||||
}
|
||||
setConnectState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/telegram/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ botToken: trimmedToken }),
|
||||
});
|
||||
const data = (await res.json()) as { error?: string };
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to save bot token");
|
||||
}
|
||||
setSuccess("Bot token saved");
|
||||
setBotToken("");
|
||||
await loadSettings();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save bot token");
|
||||
} finally {
|
||||
setConnectState("idle");
|
||||
}
|
||||
}}
|
||||
disabled={!botToken.trim() || isBusy}
|
||||
>
|
||||
{connectState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="size-4" />
|
||||
Save Token
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telegram-public-base-url">Public Base URL</Label>
|
||||
<Input
|
||||
id="telegram-public-base-url"
|
||||
type="text"
|
||||
value={publicBaseUrl}
|
||||
onChange={(e) => setPublicBaseUrl(e.target.value)}
|
||||
placeholder="https://your-public-host.example.com"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Webhook endpoint:{" "}
|
||||
<span className="font-mono">{publicBaseUrl || "https://..."}/api/integrations/telegram</span>
|
||||
</p>
|
||||
</div>
|
||||
{/* Step 2: Connection Mode */}
|
||||
{hasTokenConfigured && (
|
||||
<section className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium">2. Connection Mode</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how Telegram connects to your bot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={connectTelegram} disabled={!canConnect || isBusy}>
|
||||
{connectState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="size-4" />
|
||||
Connect Telegram
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-sm space-y-1">
|
||||
<div>
|
||||
Token source: {sourceLabel(tokenSource)}
|
||||
{storedMaskedToken ? ` (${storedMaskedToken})` : ""}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="telegram-mode" className="text-sm">Mode</Label>
|
||||
<select
|
||||
id="telegram-mode"
|
||||
value={mode}
|
||||
onChange={(e) => saveMode(e.target.value as TelegramMode)}
|
||||
disabled={isBusy}
|
||||
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="auto">Auto (recommended)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="polling">Long Polling</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm">Active Mode</Label>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||
{effectiveMode === "webhook" ? (
|
||||
<>
|
||||
<Globe className="size-4 text-blue-500" />
|
||||
<span>Webhook</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Radio className="size-4 text-green-500" />
|
||||
<span>Long Polling</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "auto" && (
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
<strong>Auto mode:</strong>{" "}
|
||||
{detectedMode === "webhook"
|
||||
? "Webhook will be used when a public HTTPS URL is configured."
|
||||
: "Long polling is active. Add a public HTTPS URL to switch to webhook."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook URL Input - only show when webhook mode is active */}
|
||||
{effectiveMode === "webhook" && (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<Label htmlFor="telegram-public-base-url">Public Base URL (HTTPS required)</Label>
|
||||
<Input
|
||||
id="telegram-public-base-url"
|
||||
type="text"
|
||||
value={publicBaseUrl}
|
||||
onChange={(e) => {
|
||||
const newUrl = e.target.value;
|
||||
setPublicBaseUrl(newUrl);
|
||||
const detected = detectUrlMode(newUrl);
|
||||
setDetectedMode(detected);
|
||||
}}
|
||||
placeholder="https://your-public-host.example.com"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Webhook endpoint:{" "}
|
||||
<span className="font-mono">{publicBaseUrl || "https://..."}/api/integrations/telegram</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
<Button
|
||||
onClick={connectTelegram}
|
||||
disabled={!publicBaseUrl.trim() || isBusy}
|
||||
size="sm"
|
||||
>
|
||||
{connectState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="size-4" />
|
||||
Setup Webhook
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Polling Controls - only show when polling mode is active */}
|
||||
{effectiveMode === "polling" && (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Long Polling</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bot will receive messages via long polling (no HTTPS required).
|
||||
</p>
|
||||
</div>
|
||||
{!pollingStatus?.polling?.isRunning ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={startPolling}
|
||||
disabled={isBusy}
|
||||
size="sm"
|
||||
>
|
||||
{pollingState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-4" />
|
||||
Start Polling
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={stopPolling}
|
||||
disabled={isBusy}
|
||||
size="sm"
|
||||
>
|
||||
{pollingState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className="size-4" />
|
||||
Stop Polling
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pollingStatus?.polling && (
|
||||
<div className="text-sm space-y-1 pt-2 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
Status:{" "}
|
||||
{pollingStatus.polling.isRunning ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Running
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">Stopped</span>
|
||||
)}
|
||||
</div>
|
||||
{pollingStatus.polling.lastUpdateId !== null && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last update ID: {pollingStatus.polling.lastUpdateId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Connected Status */}
|
||||
{hasTokenConfigured && (
|
||||
<section className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Connection Status</h4>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-sm space-y-1">
|
||||
<div>
|
||||
Token: {sourceLabel(tokenSource)}
|
||||
{storedMaskedToken ? ` (${storedMaskedToken})` : ""}
|
||||
</div>
|
||||
{publicBaseUrl && (
|
||||
<div>
|
||||
Public Base URL:{" "}
|
||||
<span className="font-mono text-xs break-all">{publicBaseUrl}</span>
|
||||
</div>
|
||||
{updatedAtLabel && (
|
||||
<div className="text-xs text-muted-foreground">Updated: {updatedAtLabel}</div>
|
||||
)}
|
||||
)}
|
||||
<div>
|
||||
Mode: <span className="font-medium">{effectiveMode === "webhook" ? "Webhook" : "Long Polling"}</span>
|
||||
</div>
|
||||
{updatedAtLabel && (
|
||||
<div className="text-xs text-muted-foreground">Updated: {updatedAtLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={reconnectTelegram}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{reconnectState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Reconnecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="size-4" />
|
||||
Reconnect Telegram
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={disconnectTelegram}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{disconnectState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="size-4" />
|
||||
Disconnect Telegram
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={disconnectTelegram}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{disconnectState === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="size-4" />
|
||||
Disconnect
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -518,50 +792,6 @@ export function TelegramIntegrationManager() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isConnected && (
|
||||
<section className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Webhook Status</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current webhook status from the latest check.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{webhookState === "loading" && (
|
||||
<p className="text-sm text-muted-foreground">Loading webhook status...</p>
|
||||
)}
|
||||
|
||||
{webhookStatus?.webhook && (
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-sm space-y-1">
|
||||
<div>
|
||||
URL:{" "}
|
||||
<span className="font-mono text-xs break-all">
|
||||
{webhookStatus.webhook.url || "(empty)"}
|
||||
</span>
|
||||
</div>
|
||||
<div>Pending updates: {webhookStatus.webhook.pendingUpdateCount}</div>
|
||||
{webhookStatus.webhook.lastErrorMessage && (
|
||||
<div className="text-red-600">
|
||||
Last error: {webhookStatus.webhook.lastErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
{webhookStatus.webhook.lastErrorDate && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last error at:{" "}
|
||||
{new Date(webhookStatus.webhook.lastErrorDate * 1000).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhookState !== "loading" && !webhookStatus?.webhook && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{webhookStatus?.message || "Webhook status is not loaded yet."}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{success && <p className="text-sm text-emerald-600">{success}</p>}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
|
||||
12
src/instrumentation.ts
Normal file
12
src/instrumentation.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { initTelegramLifecycle } = await import("@/lib/telegram/polling-lifecycle");
|
||||
await initTelegramLifecycle();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Telegram lifecycle:", error);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const TELEGRAM_SETTINGS_FILE = path.join(
|
||||
);
|
||||
|
||||
export type TelegramConfigSource = "stored" | "env" | "none";
|
||||
export type TelegramMode = "auto" | "webhook" | "polling";
|
||||
|
||||
interface TelegramAccessCodeRecord {
|
||||
hash: string;
|
||||
@@ -24,6 +25,8 @@ interface TelegramIntegrationFileRecord {
|
||||
defaultProjectId?: string;
|
||||
allowedUserIds?: unknown;
|
||||
accessCodes?: unknown;
|
||||
mode?: unknown;
|
||||
pollingInterval?: unknown;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -35,6 +38,8 @@ export interface TelegramIntegrationStoredSettings {
|
||||
defaultProjectId: string;
|
||||
allowedUserIds: string[];
|
||||
accessCodes: TelegramAccessCodeRecord[];
|
||||
mode: TelegramMode;
|
||||
pollingInterval: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -45,9 +50,13 @@ export interface TelegramIntegrationRuntimeConfig {
|
||||
publicBaseUrl: string;
|
||||
defaultProjectId: string;
|
||||
allowedUserIds: string[];
|
||||
mode: TelegramMode;
|
||||
pollingInterval: number;
|
||||
detectedMode: TelegramMode;
|
||||
sources: {
|
||||
botToken: TelegramConfigSource;
|
||||
webhookSecret: TelegramConfigSource;
|
||||
mode: TelegramConfigSource;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +230,65 @@ async function readStoredRecord(): Promise<TelegramIntegrationFileRecord> {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMode(raw: unknown): TelegramMode {
|
||||
if (raw === "webhook" || raw === "polling") return raw;
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function normalizePollingInterval(raw: unknown): number {
|
||||
const numeric = typeof raw === "number" && Number.isFinite(raw) ? raw : 5000;
|
||||
return Math.max(1000, Math.min(60000, numeric));
|
||||
}
|
||||
|
||||
function isPrivateIpv4Address(hostname: string): boolean {
|
||||
const octets = hostname.split(".").map((part) => Number(part));
|
||||
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [first, second] = octets;
|
||||
return (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 192 && second === 168) ||
|
||||
(first === 172 && second >= 16 && second <= 31)
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalhostUrl(url: string): boolean {
|
||||
if (!url) return true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (hostname === "localhost" || hostname === "::1" || hostname.endsWith(".local")) {
|
||||
return true;
|
||||
}
|
||||
if (isPrivateIpv4Address(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
/^fe[89ab][0-9a-f]*:/i.test(hostname) ||
|
||||
/^(fc|fd)[0-9a-f]*:/i.test(hostname)
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectTelegramMode(config: {
|
||||
mode: TelegramMode;
|
||||
publicBaseUrl: string;
|
||||
}): "webhook" | "polling" {
|
||||
if (config.mode !== "auto") return config.mode;
|
||||
if (isLocalhostUrl(config.publicBaseUrl)) return "polling";
|
||||
return "webhook";
|
||||
}
|
||||
|
||||
function normalizeStoredRecord(
|
||||
record: TelegramIntegrationFileRecord
|
||||
): TelegramIntegrationStoredSettings {
|
||||
@@ -231,6 +299,8 @@ function normalizeStoredRecord(
|
||||
defaultProjectId: trimString(record.defaultProjectId),
|
||||
allowedUserIds: normalizeAllowedUserIds(record.allowedUserIds),
|
||||
accessCodes: normalizeAccessCodeRecords(record.accessCodes),
|
||||
mode: normalizeMode(record.mode),
|
||||
pollingInterval: normalizePollingInterval(record.pollingInterval),
|
||||
createdAt: trimString(record.createdAt),
|
||||
updatedAt: trimString(record.updatedAt),
|
||||
};
|
||||
@@ -259,6 +329,8 @@ export async function saveTelegramIntegrationStoredSettings(input: {
|
||||
defaultProjectId?: string;
|
||||
allowedUserIds?: string[];
|
||||
accessCodes?: TelegramAccessCodeRecord[];
|
||||
mode?: TelegramMode;
|
||||
pollingInterval?: number;
|
||||
}): Promise<TelegramIntegrationStoredSettings> {
|
||||
const current = await getTelegramIntegrationStoredSettings();
|
||||
|
||||
@@ -284,6 +356,12 @@ export async function saveTelegramIntegrationStoredSettings(input: {
|
||||
input.accessCodes !== undefined
|
||||
? normalizeAccessCodeRecords(input.accessCodes)
|
||||
: current.accessCodes;
|
||||
const nextMode =
|
||||
input.mode !== undefined ? normalizeMode(input.mode) : current.mode;
|
||||
const nextPollingInterval =
|
||||
input.pollingInterval !== undefined
|
||||
? normalizePollingInterval(input.pollingInterval)
|
||||
: current.pollingInterval;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const next: TelegramIntegrationStoredSettings = {
|
||||
@@ -293,6 +371,8 @@ export async function saveTelegramIntegrationStoredSettings(input: {
|
||||
defaultProjectId: nextDefaultProjectId,
|
||||
allowedUserIds: nextAllowedUserIds,
|
||||
accessCodes: nextAccessCodes,
|
||||
mode: nextMode,
|
||||
pollingInterval: nextPollingInterval,
|
||||
createdAt: current.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -310,6 +390,7 @@ export async function getTelegramIntegrationRuntimeConfig(): Promise<TelegramInt
|
||||
const envAllowedUserIds = parseAllowedUserIdsFromEnv(
|
||||
trimString(process.env.TELEGRAM_ALLOWED_USER_IDS)
|
||||
);
|
||||
const envMode = normalizeMode(process.env.TELEGRAM_MODE);
|
||||
|
||||
const botToken = stored.botToken || envBotToken;
|
||||
const webhookSecret = stored.webhookSecret || envWebhookSecret;
|
||||
@@ -319,6 +400,8 @@ export async function getTelegramIntegrationRuntimeConfig(): Promise<TelegramInt
|
||||
stored.allowedUserIds,
|
||||
envAllowedUserIds
|
||||
);
|
||||
const mode = stored.mode !== "auto" ? stored.mode : envMode !== "auto" ? envMode : "auto";
|
||||
const pollingInterval = stored.pollingInterval || 5000;
|
||||
|
||||
const botTokenSource: TelegramConfigSource = stored.botToken
|
||||
? "stored"
|
||||
@@ -330,6 +413,13 @@ export async function getTelegramIntegrationRuntimeConfig(): Promise<TelegramInt
|
||||
: envWebhookSecret
|
||||
? "env"
|
||||
: "none";
|
||||
const modeSource: TelegramConfigSource = stored.mode !== "auto"
|
||||
? "stored"
|
||||
: envMode !== "auto"
|
||||
? "env"
|
||||
: "none";
|
||||
|
||||
const detectedMode = detectTelegramMode({ mode, publicBaseUrl });
|
||||
|
||||
return {
|
||||
botToken,
|
||||
@@ -337,9 +427,13 @@ export async function getTelegramIntegrationRuntimeConfig(): Promise<TelegramInt
|
||||
publicBaseUrl,
|
||||
defaultProjectId,
|
||||
allowedUserIds,
|
||||
mode,
|
||||
pollingInterval,
|
||||
detectedMode,
|
||||
sources: {
|
||||
botToken: botTokenSource,
|
||||
webhookSecret: webhookSecretSource,
|
||||
mode: modeSource,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -350,11 +444,15 @@ export async function getTelegramIntegrationPublicSettings(): Promise<{
|
||||
publicBaseUrl: string;
|
||||
defaultProjectId: string;
|
||||
allowedUserIds: string[];
|
||||
mode: TelegramMode;
|
||||
pollingInterval: number;
|
||||
detectedMode: TelegramMode;
|
||||
pendingAccessCodes: number;
|
||||
updatedAt: string | null;
|
||||
sources: {
|
||||
botToken: TelegramConfigSource;
|
||||
webhookSecret: TelegramConfigSource;
|
||||
mode: TelegramConfigSource;
|
||||
};
|
||||
}> {
|
||||
const stored = await getTelegramIntegrationStoredSettings();
|
||||
@@ -365,6 +463,9 @@ export async function getTelegramIntegrationPublicSettings(): Promise<{
|
||||
publicBaseUrl: runtime.publicBaseUrl,
|
||||
defaultProjectId: runtime.defaultProjectId,
|
||||
allowedUserIds: runtime.allowedUserIds,
|
||||
mode: runtime.mode,
|
||||
pollingInterval: runtime.pollingInterval,
|
||||
detectedMode: runtime.detectedMode,
|
||||
pendingAccessCodes: stored.accessCodes.length,
|
||||
updatedAt: stored.updatedAt || null,
|
||||
sources: runtime.sources,
|
||||
@@ -377,6 +478,8 @@ export async function saveTelegramIntegrationFromPublicInput(input: {
|
||||
publicBaseUrl?: unknown;
|
||||
defaultProjectId?: unknown;
|
||||
allowedUserIds?: unknown;
|
||||
mode?: unknown;
|
||||
pollingInterval?: unknown;
|
||||
}): Promise<void> {
|
||||
const currentStored = await getTelegramIntegrationStoredSettings();
|
||||
|
||||
@@ -407,6 +510,14 @@ export async function saveTelegramIntegrationFromPublicInput(input: {
|
||||
? input.defaultProjectId
|
||||
: undefined;
|
||||
const allowedUserIds = parseAllowedUserIdsInput(input.allowedUserIds);
|
||||
const mode =
|
||||
typeof input.mode === "string"
|
||||
? normalizeMode(input.mode)
|
||||
: undefined;
|
||||
const pollingInterval =
|
||||
typeof input.pollingInterval === "number"
|
||||
? normalizePollingInterval(input.pollingInterval)
|
||||
: undefined;
|
||||
|
||||
await saveTelegramIntegrationStoredSettings({
|
||||
botToken,
|
||||
@@ -414,6 +525,8 @@ export async function saveTelegramIntegrationFromPublicInput(input: {
|
||||
publicBaseUrl,
|
||||
defaultProjectId,
|
||||
allowedUserIds,
|
||||
mode,
|
||||
pollingInterval,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
159
src/lib/telegram/polling-lifecycle.ts
Normal file
159
src/lib/telegram/polling-lifecycle.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
generateTelegramWebhookSecret,
|
||||
getTelegramIntegrationRuntimeConfig,
|
||||
saveTelegramIntegrationStoredSettings,
|
||||
detectTelegramMode,
|
||||
type TelegramIntegrationRuntimeConfig,
|
||||
} from "@/lib/storage/telegram-integration-store";
|
||||
import { telegramPollingService } from "@/lib/telegram/polling-service";
|
||||
|
||||
let lifecycleInitialized = false;
|
||||
|
||||
interface TelegramLifecycleOptions {
|
||||
autoStartPolling?: boolean;
|
||||
autoSetupWebhook?: boolean;
|
||||
}
|
||||
|
||||
export async function initTelegramLifecycle(
|
||||
options: TelegramLifecycleOptions = {}
|
||||
): Promise<void> {
|
||||
if (lifecycleInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = await getTelegramIntegrationRuntimeConfig();
|
||||
const detectedMode = detectTelegramMode(runtime);
|
||||
|
||||
console.log(`[Telegram Lifecycle] Mode: ${runtime.mode}, Detected: ${detectedMode}`);
|
||||
|
||||
if (detectedMode === "polling") {
|
||||
if (options.autoStartPolling !== false && runtime.botToken.trim()) {
|
||||
try {
|
||||
await telegramPollingService.start(runtime);
|
||||
console.log("[Telegram Lifecycle] Polling started automatically");
|
||||
} catch (error) {
|
||||
console.error("[Telegram Lifecycle] Failed to start polling:", error);
|
||||
}
|
||||
}
|
||||
} else if (detectedMode === "webhook") {
|
||||
if (options.autoSetupWebhook !== false && runtime.botToken.trim() && runtime.publicBaseUrl.trim()) {
|
||||
try {
|
||||
await setupTelegramWebhook(runtime);
|
||||
console.log("[Telegram Lifecycle] Webhook configured");
|
||||
} catch (error) {
|
||||
console.error("[Telegram Lifecycle] Failed to setup webhook:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupGracefulShutdown();
|
||||
lifecycleInitialized = true;
|
||||
}
|
||||
|
||||
async function setupTelegramWebhook(
|
||||
runtime: TelegramIntegrationRuntimeConfig
|
||||
): Promise<void> {
|
||||
const { botToken, publicBaseUrl, webhookSecret } = runtime;
|
||||
|
||||
if (!botToken.trim() || !publicBaseUrl.trim()) {
|
||||
throw new Error("Bot token and public base URL are required");
|
||||
}
|
||||
|
||||
let effectiveWebhookSecret = webhookSecret.trim();
|
||||
if (!effectiveWebhookSecret) {
|
||||
effectiveWebhookSecret = generateTelegramWebhookSecret();
|
||||
await saveTelegramIntegrationStoredSettings({
|
||||
webhookSecret: effectiveWebhookSecret,
|
||||
});
|
||||
}
|
||||
|
||||
const webhookUrl = `${publicBaseUrl.replace(/\/$/, "")}/api/integrations/telegram`;
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${botToken}/setWebhook`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: webhookUrl,
|
||||
secret_token: effectiveWebhookSecret,
|
||||
allowed_updates: ["message"],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { ok?: boolean; description?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(
|
||||
`Failed to set webhook: ${payload?.description || response.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateToWebhook(
|
||||
runtime: TelegramIntegrationRuntimeConfig
|
||||
): Promise<void> {
|
||||
// Stop polling if running
|
||||
if (telegramPollingService.status.isRunning) {
|
||||
telegramPollingService.stop();
|
||||
console.log("[Telegram Migration] Polling stopped");
|
||||
}
|
||||
|
||||
// Setup webhook
|
||||
await setupTelegramWebhook(runtime);
|
||||
console.log("[Telegram Migration] Migrated to webhook mode");
|
||||
}
|
||||
|
||||
export async function migrateToPolling(
|
||||
runtime: TelegramIntegrationRuntimeConfig
|
||||
): Promise<void> {
|
||||
// Delete webhook
|
||||
await deleteTelegramWebhook(runtime.botToken);
|
||||
|
||||
// Start polling
|
||||
if (!telegramPollingService.status.isRunning) {
|
||||
await telegramPollingService.start(runtime);
|
||||
console.log("[Telegram Migration] Migrated to polling mode");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTelegramWebhook(botToken: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${botToken}/deleteWebhook`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ drop_pending_updates: false }),
|
||||
}
|
||||
);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { ok?: boolean; description?: string }
|
||||
| null;
|
||||
|
||||
if (payload?.ok) {
|
||||
console.log("[Telegram Lifecycle] Webhook deleted");
|
||||
} else {
|
||||
console.warn("[Telegram Lifecycle] Failed to delete webhook:", payload?.description);
|
||||
}
|
||||
}
|
||||
|
||||
function setupGracefulShutdown(): void {
|
||||
const shutdown = () => {
|
||||
console.log("[Telegram Lifecycle] Shutting down...");
|
||||
if (telegramPollingService.status.isRunning) {
|
||||
telegramPollingService.stop();
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
export function isLifecycleInitialized(): boolean {
|
||||
return lifecycleInitialized;
|
||||
}
|
||||
267
src/lib/telegram/polling-service.ts
Normal file
267
src/lib/telegram/polling-service.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
detectTelegramMode,
|
||||
getTelegramIntegrationRuntimeConfig,
|
||||
type TelegramIntegrationRuntimeConfig,
|
||||
} from "@/lib/storage/telegram-integration-store";
|
||||
import {
|
||||
processTelegramUpdate,
|
||||
type TelegramUpdate,
|
||||
} from "@/lib/telegram/telegram-message-handler";
|
||||
|
||||
interface TelegramApiResponse {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
result?: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface PollingStatus {
|
||||
isRunning: boolean;
|
||||
lastUpdateId: number | null;
|
||||
lastPollTime: string | null;
|
||||
errorCount: number;
|
||||
consecutiveErrors: number;
|
||||
}
|
||||
|
||||
class TelegramPollingService {
|
||||
private isRunning = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private lastUpdateId: number | null = null;
|
||||
private errorCount = 0;
|
||||
private consecutiveErrors = 0;
|
||||
private lastPollTime: string | null = null;
|
||||
private runtimeConfig: TelegramIntegrationRuntimeConfig | null = null;
|
||||
private pollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
private extractUpdateId(update: TelegramUpdate): number | null {
|
||||
return typeof update.update_id === "number" && Number.isInteger(update.update_id)
|
||||
? update.update_id
|
||||
: null;
|
||||
}
|
||||
|
||||
get status(): PollingStatus {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
lastUpdateId: this.lastUpdateId,
|
||||
lastPollTime: this.lastPollTime,
|
||||
errorCount: this.errorCount,
|
||||
consecutiveErrors: this.consecutiveErrors,
|
||||
};
|
||||
}
|
||||
|
||||
async start(runtimeConfig: TelegramIntegrationRuntimeConfig): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
throw new Error("Polling is already running");
|
||||
}
|
||||
|
||||
if (!runtimeConfig.botToken.trim()) {
|
||||
throw new Error("Bot token is required");
|
||||
}
|
||||
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
this.isRunning = true;
|
||||
this.abortController = new AbortController();
|
||||
this.errorCount = 0;
|
||||
this.consecutiveErrors = 0;
|
||||
|
||||
console.log("[Telegram Polling] Starting polling service...");
|
||||
|
||||
// Delete webhook if exists to ensure polling works
|
||||
await this.deleteWebhook(runtimeConfig.botToken);
|
||||
|
||||
// Start first poll immediately
|
||||
this.scheduleNextPoll(0);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Telegram Polling] Stopping polling service...");
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.pollTimeout) {
|
||||
clearTimeout(this.pollTimeout);
|
||||
this.pollTimeout = null;
|
||||
}
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
this.runtimeConfig = null;
|
||||
}
|
||||
|
||||
private scheduleNextPoll(delay?: number): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDelay = delay ?? this.runtimeConfig?.pollingInterval ?? 5000;
|
||||
|
||||
this.pollTimeout = setTimeout(() => {
|
||||
this.poll();
|
||||
}, actualDelay);
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeConfig = await getTelegramIntegrationRuntimeConfig();
|
||||
const botToken = runtimeConfig.botToken.trim();
|
||||
if (!botToken) {
|
||||
throw new Error("Bot token is required");
|
||||
}
|
||||
|
||||
const detectedMode = detectTelegramMode(runtimeConfig);
|
||||
if (detectedMode !== "polling") {
|
||||
console.log("[Telegram Polling] Detected mode is webhook, stopping polling service");
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
const updates = await this.getUpdates(botToken);
|
||||
|
||||
this.consecutiveErrors = 0;
|
||||
this.lastPollTime = new Date().toISOString();
|
||||
|
||||
let retrySoon = false;
|
||||
|
||||
for (const update of updates) {
|
||||
if (!this.isRunning) break;
|
||||
const updateId = this.extractUpdateId(update);
|
||||
if (updateId === null) {
|
||||
console.warn("[Telegram Polling] Received update without valid update_id, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
const processed = await this.processUpdate(update, runtimeConfig);
|
||||
if (!processed) {
|
||||
retrySoon = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Confirm only successfully processed updates to avoid data loss.
|
||||
this.lastUpdateId = updateId;
|
||||
}
|
||||
|
||||
if (retrySoon) {
|
||||
this.scheduleNextPoll(1000);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.isRunning &&
|
||||
error instanceof Error &&
|
||||
error.name === "AbortError"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorCount++;
|
||||
this.consecutiveErrors++;
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Telegram Polling] Error (consecutive: ${this.consecutiveErrors}):`, errorMessage);
|
||||
|
||||
if (this.consecutiveErrors >= 10) {
|
||||
console.error("[Telegram Polling] Too many consecutive errors, stopping polling");
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleNextPoll();
|
||||
}
|
||||
|
||||
private async getUpdates(botToken: string): Promise<TelegramUpdate[]> {
|
||||
const params: Record<string, unknown> = {
|
||||
limit: 100,
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
if (this.lastUpdateId !== null) {
|
||||
params.offset = this.lastUpdateId + 1;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${botToken}/getUpdates`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
signal: this.abortController?.signal,
|
||||
}
|
||||
);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| TelegramApiResponse
|
||||
| null;
|
||||
|
||||
if (!response.ok || !payload?.ok) {
|
||||
const description = payload?.description || "Unknown error";
|
||||
throw new Error(`getUpdates failed (${response.status}): ${description}`);
|
||||
}
|
||||
|
||||
const result = payload.result;
|
||||
if (!Array.isArray(result)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result as TelegramUpdate[];
|
||||
}
|
||||
|
||||
private async processUpdate(
|
||||
update: TelegramUpdate,
|
||||
runtimeConfig: TelegramIntegrationRuntimeConfig
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await processTelegramUpdate(update, runtimeConfig);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.errorCount++;
|
||||
this.consecutiveErrors++;
|
||||
console.error("[Telegram Polling] Error processing update:", error);
|
||||
|
||||
if (this.consecutiveErrors >= 10) {
|
||||
console.error("[Telegram Polling] Too many consecutive processing errors, stopping polling");
|
||||
this.stop();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteWebhook(botToken: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${botToken}/deleteWebhook`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ drop_pending_updates: true }),
|
||||
}
|
||||
);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| TelegramApiResponse
|
||||
| null;
|
||||
|
||||
if (payload?.ok) {
|
||||
console.log("[Telegram Polling] Webhook deleted successfully");
|
||||
} else {
|
||||
console.warn("[Telegram Polling] Failed to delete webhook:", payload?.description);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Telegram Polling] Error deleting webhook:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const telegramPollingService = new TelegramPollingService();
|
||||
711
src/lib/telegram/telegram-message-handler.ts
Normal file
711
src/lib/telegram/telegram-message-handler.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
import {
|
||||
handleExternalMessage,
|
||||
ExternalMessageError,
|
||||
} from "@/lib/external/handle-external-message";
|
||||
import {
|
||||
createDefaultTelegramSessionId,
|
||||
createFreshTelegramSessionId,
|
||||
getTelegramChatSessionId,
|
||||
setTelegramChatSessionId,
|
||||
} from "@/lib/storage/telegram-session-store";
|
||||
import {
|
||||
claimTelegramUpdate,
|
||||
releaseTelegramUpdate,
|
||||
} from "@/lib/storage/telegram-update-store";
|
||||
import {
|
||||
consumeTelegramAccessCode,
|
||||
normalizeTelegramUserId,
|
||||
type TelegramIntegrationRuntimeConfig,
|
||||
} from "@/lib/storage/telegram-integration-store";
|
||||
import { saveChatFile } from "@/lib/storage/chat-files-store";
|
||||
import { createChat, getChat } from "@/lib/storage/chat-store";
|
||||
import {
|
||||
contextKey,
|
||||
type ExternalSession,
|
||||
getOrCreateExternalSession,
|
||||
saveExternalSession,
|
||||
} from "@/lib/storage/external-session-store";
|
||||
import { getAllProjects } from "@/lib/storage/project-store";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const TELEGRAM_TEXT_LIMIT = 4096;
|
||||
const TELEGRAM_FILE_MAX_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id?: unknown;
|
||||
message?: TelegramMessage;
|
||||
}
|
||||
|
||||
export interface TelegramMessage {
|
||||
message_id?: unknown;
|
||||
text?: unknown;
|
||||
caption?: unknown;
|
||||
from?: {
|
||||
id?: unknown;
|
||||
};
|
||||
chat?: {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
};
|
||||
document?: {
|
||||
file_id?: unknown;
|
||||
file_name?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
photo?: Array<{
|
||||
file_id?: unknown;
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
}>;
|
||||
audio?: {
|
||||
file_id?: unknown;
|
||||
file_name?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
video?: {
|
||||
file_id?: unknown;
|
||||
file_name?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
voice?: {
|
||||
file_id?: unknown;
|
||||
mime_type?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface TelegramApiResponse {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TelegramFileResult {
|
||||
file_id?: string;
|
||||
file_unique_id?: string;
|
||||
file_size?: number;
|
||||
file_path?: string;
|
||||
}
|
||||
|
||||
export interface TelegramIncomingFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface TelegramExternalChatContext {
|
||||
chatId: string;
|
||||
projectId?: string;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
interface TelegramResolvedProjectContext {
|
||||
session: ExternalSession;
|
||||
resolvedProjectId?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export interface ProcessTelegramUpdateResult {
|
||||
ok: boolean;
|
||||
duplicate?: boolean;
|
||||
ignored?: boolean;
|
||||
reason?: string;
|
||||
command?: string;
|
||||
accessGranted?: boolean;
|
||||
userId?: string;
|
||||
fileSaved?: boolean;
|
||||
file?: {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
};
|
||||
handledError?: boolean;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
function normalizeTelegramCurrentPath(rawPath: string | undefined): string {
|
||||
const value = (rawPath ?? "").trim();
|
||||
if (!value || value === "/telegram") {
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTelegramError(status: number, payload: TelegramApiResponse | null): string {
|
||||
const description = payload?.description?.trim();
|
||||
return description
|
||||
? `Telegram API error (${status}): ${description}`
|
||||
: `Telegram API error (${status})`;
|
||||
}
|
||||
|
||||
async function callTelegramApi(
|
||||
botToken: string,
|
||||
method: string,
|
||||
body?: Record<string, unknown>
|
||||
): Promise<TelegramApiResponse> {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
|
||||
method: body ? "POST" : "GET",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| TelegramApiResponse
|
||||
| null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(parseTelegramError(response.status, payload));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getBotId(botToken: string): string {
|
||||
const [rawBotId] = botToken.trim().split(":", 1);
|
||||
const botId = rawBotId?.trim() || "default";
|
||||
return botId.replace(/[^a-zA-Z0-9._:-]/g, "_").slice(0, 128) || "default";
|
||||
}
|
||||
|
||||
function chatBelongsToProject(
|
||||
chatProjectId: string | undefined,
|
||||
projectId: string | undefined
|
||||
): boolean {
|
||||
const left = chatProjectId ?? null;
|
||||
const right = projectId ?? null;
|
||||
return left === right;
|
||||
}
|
||||
|
||||
async function ensureTelegramExternalChatContext(params: {
|
||||
sessionId: string;
|
||||
defaultProjectId?: string;
|
||||
}): Promise<TelegramExternalChatContext> {
|
||||
const { session, resolvedProjectId } = await resolveTelegramProjectContext({
|
||||
sessionId: params.sessionId,
|
||||
defaultProjectId: params.defaultProjectId,
|
||||
});
|
||||
const projectKey = contextKey(resolvedProjectId);
|
||||
let resolvedChatId = session.activeChats[projectKey];
|
||||
if (resolvedChatId) {
|
||||
const existing = await getChat(resolvedChatId);
|
||||
if (!existing || !chatBelongsToProject(existing.projectId, resolvedProjectId)) {
|
||||
resolvedChatId = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedChatId) {
|
||||
resolvedChatId = crypto.randomUUID();
|
||||
await createChat(
|
||||
resolvedChatId,
|
||||
`External session ${session.id}`,
|
||||
resolvedProjectId
|
||||
);
|
||||
}
|
||||
|
||||
session.activeChats[projectKey] = resolvedChatId;
|
||||
session.currentPaths[projectKey] = normalizeTelegramCurrentPath(
|
||||
session.currentPaths[projectKey]
|
||||
);
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await saveExternalSession(session);
|
||||
|
||||
return {
|
||||
chatId: resolvedChatId,
|
||||
projectId: resolvedProjectId,
|
||||
currentPath: session.currentPaths[projectKey] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTelegramProjectContext(params: {
|
||||
sessionId: string;
|
||||
defaultProjectId?: string;
|
||||
}): Promise<TelegramResolvedProjectContext> {
|
||||
const session = await getOrCreateExternalSession(params.sessionId);
|
||||
const projects = await getAllProjects();
|
||||
const projectById = new Map(projects.map((project) => [project.id, project]));
|
||||
|
||||
let resolvedProjectId: string | undefined;
|
||||
const explicitProjectId = params.defaultProjectId?.trim() || "";
|
||||
if (explicitProjectId) {
|
||||
if (!projectById.has(explicitProjectId)) {
|
||||
throw new Error(`Project "${explicitProjectId}" not found`);
|
||||
}
|
||||
resolvedProjectId = explicitProjectId;
|
||||
session.activeProjectId = explicitProjectId;
|
||||
} else if (session.activeProjectId && projectById.has(session.activeProjectId)) {
|
||||
resolvedProjectId = session.activeProjectId;
|
||||
} else if (projects.length > 0) {
|
||||
resolvedProjectId = projects[0].id;
|
||||
session.activeProjectId = projects[0].id;
|
||||
} else {
|
||||
session.activeProjectId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
resolvedProjectId,
|
||||
projectName: resolvedProjectId ? projectById.get(resolvedProjectId)?.name : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extensionFromMime(mimeType: string): string {
|
||||
const lower = mimeType.toLowerCase();
|
||||
if (lower.includes("pdf")) return ".pdf";
|
||||
if (lower.includes("png")) return ".png";
|
||||
if (lower.includes("jpeg") || lower.includes("jpg")) return ".jpg";
|
||||
if (lower.includes("webp")) return ".webp";
|
||||
if (lower.includes("gif")) return ".gif";
|
||||
if (lower.includes("mp4")) return ".mp4";
|
||||
if (lower.includes("mpeg") || lower.includes("mp3")) return ".mp3";
|
||||
if (lower.includes("ogg")) return ".ogg";
|
||||
if (lower.includes("wav")) return ".wav";
|
||||
if (lower.includes("plain")) return ".txt";
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildIncomingFileName(params: {
|
||||
base: string;
|
||||
messageId?: number;
|
||||
mimeType?: string;
|
||||
}): string {
|
||||
const suffix = params.messageId ?? Date.now();
|
||||
const ext = params.mimeType ? extensionFromMime(params.mimeType) : "";
|
||||
return `${params.base}-${suffix}${ext}`;
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
const base = value.trim().replace(/[\\/]+/g, "_");
|
||||
return base || `file-${Date.now()}`;
|
||||
}
|
||||
|
||||
function withMessageIdPrefix(fileName: string, messageId?: number): string {
|
||||
if (typeof messageId !== "number") return fileName;
|
||||
return `${messageId}-${fileName}`;
|
||||
}
|
||||
|
||||
export function extractIncomingFile(
|
||||
message: TelegramMessage,
|
||||
messageId?: number
|
||||
): TelegramIncomingFile | null {
|
||||
const documentFileId =
|
||||
typeof message.document?.file_id === "string"
|
||||
? message.document.file_id.trim()
|
||||
: "";
|
||||
if (documentFileId) {
|
||||
const docNameRaw =
|
||||
typeof message.document?.file_name === "string"
|
||||
? message.document.file_name
|
||||
: "";
|
||||
const fallback = buildIncomingFileName({
|
||||
base: "document",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.document?.mime_type === "string"
|
||||
? message.document.mime_type
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
fileId: documentFileId,
|
||||
fileName: withMessageIdPrefix(sanitizeFileName(docNameRaw || fallback), messageId),
|
||||
};
|
||||
}
|
||||
|
||||
const photos: Array<{ file_id?: unknown }> = Array.isArray(message.photo)
|
||||
? message.photo
|
||||
: [];
|
||||
for (let i = photos.length - 1; i >= 0; i -= 1) {
|
||||
const photo = photos[i];
|
||||
const fileId = typeof photo?.file_id === "string" ? photo.file_id.trim() : "";
|
||||
if (fileId) {
|
||||
return {
|
||||
fileId,
|
||||
fileName: sanitizeFileName(
|
||||
buildIncomingFileName({ base: "photo", messageId, mimeType: "image/jpeg" })
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const audioFileId =
|
||||
typeof message.audio?.file_id === "string" ? message.audio.file_id.trim() : "";
|
||||
if (audioFileId) {
|
||||
const audioNameRaw =
|
||||
typeof message.audio?.file_name === "string" ? message.audio.file_name : "";
|
||||
const fallback = buildIncomingFileName({
|
||||
base: "audio",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.audio?.mime_type === "string"
|
||||
? message.audio.mime_type
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
fileId: audioFileId,
|
||||
fileName: withMessageIdPrefix(sanitizeFileName(audioNameRaw || fallback), messageId),
|
||||
};
|
||||
}
|
||||
|
||||
const videoFileId =
|
||||
typeof message.video?.file_id === "string" ? message.video.file_id.trim() : "";
|
||||
if (videoFileId) {
|
||||
const videoNameRaw =
|
||||
typeof message.video?.file_name === "string" ? message.video.file_name : "";
|
||||
const fallback = buildIncomingFileName({
|
||||
base: "video",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.video?.mime_type === "string"
|
||||
? message.video.mime_type
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
fileId: videoFileId,
|
||||
fileName: withMessageIdPrefix(sanitizeFileName(videoNameRaw || fallback), messageId),
|
||||
};
|
||||
}
|
||||
|
||||
const voiceFileId =
|
||||
typeof message.voice?.file_id === "string" ? message.voice.file_id.trim() : "";
|
||||
if (voiceFileId) {
|
||||
return {
|
||||
fileId: voiceFileId,
|
||||
fileName: sanitizeFileName(
|
||||
buildIncomingFileName({
|
||||
base: "voice",
|
||||
messageId,
|
||||
mimeType:
|
||||
typeof message.voice?.mime_type === "string"
|
||||
? message.voice.mime_type
|
||||
: undefined,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadTelegramFile(botToken: string, fileId: string): Promise<Buffer> {
|
||||
const payload = await callTelegramApi(botToken, "getFile", {
|
||||
file_id: fileId,
|
||||
});
|
||||
const result = payload.result as TelegramFileResult | undefined;
|
||||
const filePath = result?.file_path ?? "";
|
||||
if (!filePath) {
|
||||
throw new Error("Telegram getFile returned empty file_path");
|
||||
}
|
||||
|
||||
const fileUrl = `https://api.telegram.org/file/bot${botToken}/${filePath}`;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download Telegram file (${response.status})`);
|
||||
}
|
||||
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength > TELEGRAM_FILE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Telegram file is too large (${bytes.byteLength} bytes). Max supported size is ${TELEGRAM_FILE_MAX_BYTES} bytes.`
|
||||
);
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function extractCommand(text: string): string | null {
|
||||
const first = text.trim().split(/\s+/, 1)[0];
|
||||
if (!first || !first.startsWith("/")) return null;
|
||||
return first.split("@", 1)[0].toLowerCase();
|
||||
}
|
||||
|
||||
function extractAccessCodeCandidate(text: string): string | null {
|
||||
const value = text.trim();
|
||||
if (!value) return null;
|
||||
|
||||
const fromCommand = value.match(
|
||||
/^\/(?:code|start)(?:@[a-zA-Z0-9_]+)?\s+([A-Za-z0-9_-]{6,64})$/i
|
||||
);
|
||||
if (fromCommand?.[1]) {
|
||||
return fromCommand[1];
|
||||
}
|
||||
|
||||
if (/^[A-Za-z0-9_-]{6,64}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeOutgoingText(text: string): string {
|
||||
const value = text.trim();
|
||||
if (!value) return "Пустой ответ от агента.";
|
||||
if (value.length <= TELEGRAM_TEXT_LIMIT) return value;
|
||||
return `${value.slice(0, TELEGRAM_TEXT_LIMIT - 1)}…`;
|
||||
}
|
||||
|
||||
export async function sendTelegramMessage(
|
||||
botToken: string,
|
||||
chatId: number | string,
|
||||
text: string,
|
||||
replyToMessageId?: number
|
||||
): Promise<void> {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: normalizeOutgoingText(text),
|
||||
...(typeof replyToMessageId === "number" ? { reply_to_message_id: replyToMessageId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { ok?: boolean; description?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(
|
||||
`Telegram sendMessage failed (${response.status})${payload?.description ? `: ${payload.description}` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function helpText(activeProject?: { id?: string; name?: string }): string {
|
||||
const activeProjectLine = activeProject?.id
|
||||
? `Active project: ${activeProject.name ? `${activeProject.name} (${activeProject.id})` : activeProject.id}`
|
||||
: "Active project: not selected";
|
||||
return [
|
||||
"Telegram connection is active.",
|
||||
activeProjectLine,
|
||||
"",
|
||||
"Commands:",
|
||||
"/start - show this help",
|
||||
"/help - show this help",
|
||||
"/code <access_code> - activate access for your Telegram user",
|
||||
"/new - start a new conversation (reset context)",
|
||||
"",
|
||||
"Text messages are sent to the agent.",
|
||||
"File uploads are saved into chat files.",
|
||||
"You can also ask the agent to send a local file back to Telegram.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function processTelegramUpdate(
|
||||
update: TelegramUpdate,
|
||||
runtime: TelegramIntegrationRuntimeConfig
|
||||
): Promise<ProcessTelegramUpdateResult> {
|
||||
const botToken = runtime.botToken.trim();
|
||||
const defaultProjectId = runtime.defaultProjectId || undefined;
|
||||
const allowedUserIds = new Set(runtime.allowedUserIds);
|
||||
|
||||
if (!botToken) {
|
||||
throw new Error("Telegram bot token is not configured");
|
||||
}
|
||||
|
||||
const updateId =
|
||||
typeof update.update_id === "number" && Number.isInteger(update.update_id)
|
||||
? update.update_id
|
||||
: null;
|
||||
if (updateId === null) {
|
||||
throw new Error("Invalid update_id");
|
||||
}
|
||||
|
||||
const botId = getBotId(botToken);
|
||||
const isNewUpdate = await claimTelegramUpdate(botId, updateId);
|
||||
if (!isNewUpdate) {
|
||||
return { ok: true, duplicate: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const message = update.message;
|
||||
const chatId =
|
||||
typeof message?.chat?.id === "number" || typeof message?.chat?.id === "string"
|
||||
? message.chat.id
|
||||
: null;
|
||||
const chatType = typeof message?.chat?.type === "string" ? message.chat.type : "";
|
||||
const messageId =
|
||||
typeof message?.message_id === "number" ? message.message_id : undefined;
|
||||
|
||||
if (chatId === null || !chatType) {
|
||||
return { ok: true, ignored: true, reason: "unsupported_update" };
|
||||
}
|
||||
|
||||
if (chatType !== "private") {
|
||||
return { ok: true, ignored: true, reason: "private_only" };
|
||||
}
|
||||
|
||||
const text = typeof message?.text === "string" ? message.text.trim() : "";
|
||||
const caption =
|
||||
typeof message?.caption === "string" ? message.caption.trim() : "";
|
||||
const incomingText = text || caption;
|
||||
const fromUserId = normalizeTelegramUserId(message?.from?.id);
|
||||
|
||||
if (!fromUserId) {
|
||||
return {
|
||||
ok: true,
|
||||
ignored: true,
|
||||
reason: "missing_user_id",
|
||||
};
|
||||
}
|
||||
|
||||
if (!allowedUserIds.has(fromUserId)) {
|
||||
const accessCode = extractAccessCodeCandidate(text);
|
||||
const granted =
|
||||
accessCode &&
|
||||
(await consumeTelegramAccessCode({
|
||||
code: accessCode,
|
||||
userId: fromUserId,
|
||||
}));
|
||||
|
||||
if (granted) {
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Доступ выдан. Теперь можно отправлять сообщения агенту.",
|
||||
messageId
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
accessGranted: true,
|
||||
userId: fromUserId,
|
||||
};
|
||||
}
|
||||
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
[
|
||||
"Доступ запрещён: ваш user_id не в списке разрешённых.",
|
||||
"Отправьте код активации командой /code <код> или /start <код>.",
|
||||
`Ваш user_id: ${fromUserId}`,
|
||||
].join("\n"),
|
||||
messageId
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
ignored: true,
|
||||
reason: "user_not_allowed",
|
||||
userId: fromUserId,
|
||||
};
|
||||
}
|
||||
|
||||
let sessionId = await getTelegramChatSessionId(botId, chatId);
|
||||
if (!sessionId) {
|
||||
sessionId = createDefaultTelegramSessionId(botId, chatId);
|
||||
await setTelegramChatSessionId(botId, chatId, sessionId);
|
||||
}
|
||||
|
||||
const command = extractCommand(text);
|
||||
if (command === "/start" || command === "/help") {
|
||||
const resolvedProject = await resolveTelegramProjectContext({
|
||||
sessionId,
|
||||
defaultProjectId,
|
||||
});
|
||||
await saveExternalSession({
|
||||
...resolvedProject.session,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
helpText({
|
||||
id: resolvedProject.resolvedProjectId,
|
||||
name: resolvedProject.projectName,
|
||||
}),
|
||||
messageId
|
||||
);
|
||||
return { ok: true, command };
|
||||
}
|
||||
|
||||
if (command === "/new") {
|
||||
const freshSessionId = createFreshTelegramSessionId(botId, chatId);
|
||||
await setTelegramChatSessionId(botId, chatId, freshSessionId);
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Начал новый диалог. Контекст очищен для следующего сообщения.",
|
||||
messageId
|
||||
);
|
||||
return { ok: true, command };
|
||||
}
|
||||
|
||||
let incomingSavedFile: {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
} | null = null;
|
||||
|
||||
const incomingFile = message ? extractIncomingFile(message, messageId) : null;
|
||||
let externalContext: TelegramExternalChatContext | null = null;
|
||||
if (incomingFile) {
|
||||
externalContext = await ensureTelegramExternalChatContext({
|
||||
sessionId,
|
||||
defaultProjectId,
|
||||
});
|
||||
const fileBuffer = await downloadTelegramFile(botToken, incomingFile.fileId);
|
||||
const saved = await saveChatFile(
|
||||
externalContext.chatId,
|
||||
fileBuffer,
|
||||
incomingFile.fileName
|
||||
);
|
||||
incomingSavedFile = {
|
||||
name: saved.name,
|
||||
path: saved.path,
|
||||
size: saved.size,
|
||||
};
|
||||
}
|
||||
|
||||
if (!incomingText) {
|
||||
if (incomingSavedFile) {
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
`File "${incomingSavedFile.name}" saved to chat files.`,
|
||||
messageId
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
fileSaved: true,
|
||||
file: incomingSavedFile,
|
||||
};
|
||||
}
|
||||
|
||||
await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Only text messages and file uploads are supported right now.",
|
||||
messageId
|
||||
);
|
||||
return { ok: true, ignored: true, reason: "non_text" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleExternalMessage({
|
||||
sessionId,
|
||||
message: incomingSavedFile
|
||||
? `${incomingText}\n\nAttached file: ${incomingSavedFile.name}`
|
||||
: incomingText,
|
||||
projectId: externalContext?.projectId ?? defaultProjectId,
|
||||
chatId: externalContext?.chatId,
|
||||
currentPath: normalizeTelegramCurrentPath(externalContext?.currentPath),
|
||||
runtimeData: {
|
||||
telegram: {
|
||||
botToken,
|
||||
chatId,
|
||||
replyToMessageId: messageId ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await sendTelegramMessage(botToken, chatId, result.reply, messageId);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalMessageError) {
|
||||
const errorMessage =
|
||||
typeof error.payload.error === "string"
|
||||
? error.payload.error
|
||||
: "Не удалось обработать сообщение.";
|
||||
await sendTelegramMessage(botToken, chatId, `Ошибка: ${errorMessage}`, messageId);
|
||||
return { ok: true, handledError: true, status: error.status };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
await releaseTelegramUpdate(botId, updateId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user