@aion0/forge 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { loadSettings } from '@/lib/settings';
3
+ import { handleTelegramMessage } from '@/lib/telegram-bot';
4
+
5
+ // POST /api/telegram — receives messages from telegram-standalone process
6
+ export async function POST(req: NextRequest) {
7
+ const settings = loadSettings();
8
+
9
+ // Verify the request comes from our standalone process
10
+ const secret = req.headers.get('x-telegram-secret');
11
+ if (!secret || secret !== settings.telegramBotToken) {
12
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
13
+ }
14
+
15
+ const message = await req.json();
16
+
17
+ try {
18
+ await handleTelegramMessage(message);
19
+ return NextResponse.json({ ok: true });
20
+ } catch (e: any) {
21
+ return NextResponse.json({ error: e.message }, { status: 500 });
22
+ }
23
+ }
@@ -1,12 +1,10 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { startTunnel, stopTunnel, getTunnelStatus } from '@/lib/cloudflared';
3
3
 
4
- /** GET /api/tunnel — current tunnel status */
5
4
  export async function GET() {
6
5
  return NextResponse.json(getTunnelStatus());
7
6
  }
8
7
 
9
- /** POST /api/tunnel — start or stop tunnel */
10
8
  export async function POST(req: Request) {
11
9
  const { action } = await req.json() as { action: 'start' | 'stop' };
12
10
 
@@ -11,9 +11,13 @@ export default function LoginPage() {
11
11
  e.preventDefault();
12
12
  const result = await signIn('credentials', {
13
13
  password,
14
- callbackUrl: window.location.origin + '/',
15
- }) as { error?: string } | undefined;
16
- if (result?.error) setError('Wrong password');
14
+ redirect: false,
15
+ }) as { error?: string; ok?: boolean } | undefined;
16
+ if (result?.error) {
17
+ setError('Wrong password');
18
+ } else if (result?.ok) {
19
+ window.location.href = window.location.origin + '/';
20
+ }
17
21
  };
18
22
 
19
23
  return (
@@ -93,8 +93,70 @@ if (resetTerminal) {
93
93
  }
94
94
  }
95
95
 
96
+ // ── Kill orphan standalone processes ──
97
+ function cleanupOrphans() {
98
+ try {
99
+ const out = execSync("ps aux | grep -E 'telegram-standalone|terminal-standalone|next-server|next start|next dev' | grep -v grep | awk '{print $2}'", {
100
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
101
+ }).trim();
102
+ if (out) {
103
+ const myPid = String(process.pid);
104
+ for (const pid of out.split('\n').filter(Boolean)) {
105
+ if (pid.trim() === myPid) continue; // don't kill ourselves
106
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
107
+ }
108
+ // Force kill any remaining
109
+ setTimeout(() => {
110
+ for (const pid of out.split('\n').filter(Boolean)) {
111
+ if (pid.trim() === myPid) continue;
112
+ try { process.kill(parseInt(pid), 'SIGKILL'); } catch {}
113
+ }
114
+ }, 2000);
115
+ console.log('[forge] Cleaned up orphan processes');
116
+ }
117
+ } catch {}
118
+ }
119
+
120
+ // ── Start standalone services (single instance each) ──
121
+ const services = [];
122
+
123
+ function startServices() {
124
+ cleanupOrphans();
125
+
126
+ // Terminal server
127
+ const termScript = join(ROOT, 'lib', 'terminal-standalone.ts');
128
+ const termChild = spawn('npx', ['tsx', termScript], {
129
+ cwd: ROOT,
130
+ stdio: ['ignore', 'inherit', 'inherit'],
131
+ env: { ...process.env },
132
+ });
133
+ services.push(termChild);
134
+ console.log(`[forge] Terminal server started (pid: ${termChild.pid})`);
135
+
136
+ // Telegram bot
137
+ const telegramScript = join(ROOT, 'lib', 'telegram-standalone.ts');
138
+ const telegramChild = spawn('npx', ['tsx', telegramScript], {
139
+ cwd: ROOT,
140
+ stdio: ['ignore', 'inherit', 'inherit'],
141
+ env: { ...process.env },
142
+ });
143
+ services.push(telegramChild);
144
+ console.log(`[forge] Telegram bot started (pid: ${telegramChild.pid})`);
145
+ }
146
+
147
+ function stopServices() {
148
+ for (const child of services) {
149
+ try { child.kill('SIGTERM'); } catch {}
150
+ }
151
+ services.length = 0;
152
+ cleanupOrphans();
153
+ }
154
+
96
155
  // ── Helper: stop running instance ──
97
156
  function stopServer() {
157
+ stopServices();
158
+ try { unlinkSync(join(DATA_DIR, 'tunnel-state.json')); } catch {}
159
+
98
160
  try {
99
161
  const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim());
100
162
  process.kill(pid, 'SIGTERM');
@@ -109,7 +171,7 @@ function stopServer() {
109
171
 
110
172
  // ── Helper: start background server ──
111
173
  function startBackground() {
112
- if (!existsSync(join(ROOT, '.next'))) {
174
+ if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
113
175
  console.log('[forge] Building...');
114
176
  execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
115
177
  }
@@ -124,6 +186,10 @@ function startBackground() {
124
186
 
125
187
  writeFileSync(PID_FILE, String(child.pid));
126
188
  child.unref();
189
+
190
+ // Start services in background too
191
+ startServices();
192
+
127
193
  console.log(`[forge] Started in background (pid ${child.pid})`);
128
194
  console.log(`[forge] Web: http://localhost:${webPort}`);
129
195
  console.log(`[forge] Terminal: ws://localhost:${terminalPort}`);
@@ -171,24 +237,31 @@ if (isBackground) {
171
237
  }
172
238
 
173
239
  // ── Foreground ──
240
+
241
+ // Clean up services on exit
242
+ process.on('SIGINT', () => { stopServices(); process.exit(0); });
243
+ process.on('SIGTERM', () => { stopServices(); process.exit(0); });
244
+
174
245
  if (isDev) {
175
246
  console.log(`[forge] Starting dev mode (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
247
+ startServices();
176
248
  const child = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(webPort)], {
177
249
  cwd: ROOT,
178
250
  stdio: 'inherit',
179
- env: { ...process.env },
251
+ env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
180
252
  });
181
- child.on('exit', (code) => process.exit(code || 0));
253
+ child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
182
254
  } else {
183
255
  if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
184
256
  console.log('[forge] Building...');
185
257
  execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
186
258
  }
187
259
  console.log(`[forge] Starting server (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
260
+ startServices();
188
261
  const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
189
262
  cwd: ROOT,
190
263
  stdio: 'inherit',
191
- env: { ...process.env },
264
+ env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
192
265
  });
193
- child.on('exit', (code) => process.exit(code || 0));
266
+ child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
194
267
  }
@@ -2,6 +2,33 @@
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
 
5
+ function SecretInput({ value, onChange, placeholder, className }: {
6
+ value: string;
7
+ onChange: (v: string) => void;
8
+ placeholder?: string;
9
+ className?: string;
10
+ }) {
11
+ const [show, setShow] = useState(false);
12
+ return (
13
+ <div className="relative">
14
+ <input
15
+ type={show ? 'text' : 'password'}
16
+ value={value}
17
+ onChange={e => onChange(e.target.value)}
18
+ placeholder={placeholder}
19
+ className={className}
20
+ />
21
+ <button
22
+ type="button"
23
+ onClick={() => setShow(v => !v)}
24
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
25
+ >
26
+ {show ? '🙈' : '👁'}
27
+ </button>
28
+ </div>
29
+ );
30
+ }
31
+
5
32
  interface Settings {
6
33
  projectRoots: string[];
7
34
  docRoots: string[];
@@ -213,11 +240,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
213
240
  <p className="text-[10px] text-[var(--text-secondary)]">
214
241
  Get notified when tasks complete or fail. Create a bot via @BotFather, then send /start to it and use the test button below to get your chat ID.
215
242
  </p>
216
- <input
243
+ <SecretInput
217
244
  value={settings.telegramBotToken}
218
- onChange={e => setSettings({ ...settings, telegramBotToken: e.target.value })}
245
+ onChange={v => setSettings({ ...settings, telegramBotToken: v })}
219
246
  placeholder="Bot token (from @BotFather)"
220
- className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
247
+ className="w-full px-2 py-1.5 pr-8 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
221
248
  />
222
249
  <input
223
250
  value={settings.telegramChatId}
@@ -446,11 +473,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
446
473
  <label className="text-[10px] text-[var(--text-secondary)]">
447
474
  Telegram tunnel password (for /tunnel_password command)
448
475
  </label>
449
- <input
476
+ <SecretInput
450
477
  value={settings.telegramTunnelPassword}
451
- onChange={e => setSettings({ ...settings, telegramTunnelPassword: e.target.value })}
478
+ onChange={v => setSettings({ ...settings, telegramTunnelPassword: v })}
452
479
  placeholder="Set a password to get login credentials via Telegram"
453
- className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
480
+ className="w-full px-2 py-1.5 pr-8 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
454
481
  />
455
482
  </div>
456
483
  </div>
package/dev-test.sh CHANGED
@@ -2,4 +2,4 @@
2
2
  # dev-test.sh — Start Forge test instance (port 4000, data ~/.forge-test)
3
3
 
4
4
  mkdir -p ~/.forge-test
5
- PORT=4000 TERMINAL_PORT=4001 FORGE_DATA_DIR=~/.forge-test npx next dev --turbopack -p 4000
5
+ PORT=4000 TERMINAL_PORT=4001 FORGE_DATA_DIR=~/.forge-test FORGE_EXTERNAL_SERVICES=0 npx next dev --turbopack -p 4000
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { spawn, execSync, type ChildProcess } from 'node:child_process';
7
- import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
8
8
  import { homedir, platform, arch } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import https from 'node:https';
@@ -105,6 +105,23 @@ if (!gAny[stateKey]) {
105
105
  const state: TunnelState = gAny[stateKey];
106
106
 
107
107
  const MAX_LOG_LINES = 100;
108
+ const TUNNEL_STATE_FILE = join(homedir(), '.forge', 'tunnel-state.json');
109
+
110
+ function saveTunnelState() {
111
+ try {
112
+ writeFileSync(TUNNEL_STATE_FILE, JSON.stringify({
113
+ url: state.url, status: state.status, error: state.error, pid: state.process?.pid || null,
114
+ }));
115
+ } catch {}
116
+ }
117
+
118
+ function loadTunnelState(): { url: string | null; status: string; error: string | null; pid: number | null } {
119
+ try {
120
+ return JSON.parse(readFileSync(TUNNEL_STATE_FILE, 'utf-8'));
121
+ } catch {
122
+ return { url: null, status: 'stopped', error: null, pid: null };
123
+ }
124
+ }
108
125
 
109
126
  function pushLog(line: string) {
110
127
  state.log.push(line);
@@ -112,10 +129,26 @@ function pushLog(line: string) {
112
129
  }
113
130
 
114
131
  export async function startTunnel(localPort: number = 3000): Promise<{ url?: string; error?: string }> {
132
+ // Check if this worker already has a process
115
133
  if (state.process) {
116
134
  return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
117
135
  }
118
136
 
137
+ // Check if another process already has a tunnel running
138
+ const saved = loadTunnelState();
139
+ if (saved.pid && saved.status === 'running' && saved.url) {
140
+ try { process.kill(saved.pid, 0); return { url: saved.url }; } catch {}
141
+ }
142
+
143
+ // Kill ALL existing cloudflared processes to prevent duplicates
144
+ try {
145
+ const { execSync } = require('node:child_process');
146
+ const pids = execSync("pgrep -f 'cloudflared tunnel'", { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
147
+ for (const pid of pids.split('\n').filter(Boolean)) {
148
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
149
+ }
150
+ } catch {}
151
+
119
152
  state.status = 'starting';
120
153
  state.url = null;
121
154
  state.error = null;
@@ -149,6 +182,7 @@ export async function startTunnel(localPort: number = 3000): Promise<{ url?: str
149
182
  if (urlMatch && !state.url) {
150
183
  state.url = urlMatch[1];
151
184
  state.status = 'running';
185
+ saveTunnelState();
152
186
  console.log(`[cloudflared] Tunnel URL: ${state.url}`);
153
187
  startHealthCheck();
154
188
  if (!resolved) {
@@ -178,6 +212,7 @@ export async function startTunnel(localPort: number = 3000): Promise<{ url?: str
178
212
  state.status = 'stopped';
179
213
  }
180
214
  state.url = null;
215
+ saveTunnelState();
181
216
  pushLog(`[exit] cloudflared exited with code ${code}`);
182
217
  if (!resolved) {
183
218
  resolved = true;
@@ -204,16 +239,40 @@ export function stopTunnel() {
204
239
  state.process.kill('SIGTERM');
205
240
  state.process = null;
206
241
  }
242
+ // Also kill by saved PID in case another worker started it
243
+ const saved = loadTunnelState();
244
+ if (saved.pid) {
245
+ try { process.kill(saved.pid, 'SIGTERM'); } catch {}
246
+ }
207
247
  state.url = null;
208
248
  state.status = 'stopped';
209
249
  state.error = null;
250
+ saveTunnelState();
210
251
  }
211
252
 
212
253
  export function getTunnelStatus() {
254
+ // If this worker has the process, use in-memory state
255
+ if (state.process) {
256
+ return {
257
+ status: state.status,
258
+ url: state.url,
259
+ error: state.error,
260
+ installed: isInstalled(),
261
+ log: state.log.slice(-20),
262
+ };
263
+ }
264
+ // Otherwise read from file (another worker may have started it)
265
+ const saved = loadTunnelState();
266
+ if (saved.pid && saved.status === 'running') {
267
+ try { process.kill(saved.pid, 0); } catch {
268
+ // Process dead — clear stale state
269
+ return { status: 'stopped' as const, url: null, error: null, installed: isInstalled(), log: [] };
270
+ }
271
+ }
213
272
  return {
214
- status: state.status,
215
- url: state.url,
216
- error: state.error,
273
+ status: (saved.status || 'stopped') as TunnelState['status'],
274
+ url: saved.url,
275
+ error: saved.error,
217
276
  installed: isInstalled(),
218
277
  log: state.log.slice(-20),
219
278
  };
package/lib/init.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Server-side initialization — called once on first API request.
3
- * Starts background services: task runner, Telegram bot.
2
+ * Server-side initialization — called once on first API request per worker.
3
+ * When FORGE_EXTERNAL_SERVICES=1 (set by forge-server), telegram/terminal/tunnel
4
+ * are managed externally — only task runner starts here.
4
5
  */
5
6
 
6
7
  import { ensureRunnerStarted } from './task-manager';
@@ -19,24 +20,30 @@ export function ensureInitialized() {
19
20
  if (gInit[initKey]) return;
20
21
  gInit[initKey] = true;
21
22
 
22
- // Display login password (auto-generated, rotates daily)
23
+ // Task runner is safe in every worker (DB-level coordination)
24
+ ensureRunnerStarted();
25
+
26
+ // Session watcher is safe (file-based, idempotent)
27
+ startWatcherLoop();
28
+
29
+ // If services are managed externally (forge-server), skip
30
+ if (process.env.FORGE_EXTERNAL_SERVICES === '1') {
31
+ // Password display only once
32
+ const password = getPassword();
33
+ console.log(`[init] Login password: ${password} (valid today)`);
34
+ console.log('[init] Forgot? Run: forge password');
35
+ return;
36
+ }
37
+
38
+ // Standalone mode (pnpm dev without forge-server) — start everything here
23
39
  const password = getPassword();
24
40
  console.log(`[init] Login password: ${password} (valid today)`);
25
41
  console.log('[init] Forgot? Run: forge password');
26
42
 
27
- // Start background task runner
28
- ensureRunnerStarted();
29
-
30
- // Start Telegram bot if configured
31
- startTelegramBot();
32
-
33
- // Start terminal WebSocket server as separate process (node-pty needs native module)
43
+ startTelegramBot(); // registers task event listener only
34
44
  startTerminalProcess();
45
+ startTelegramProcess(); // spawns telegram-standalone
35
46
 
36
- // Start session watcher loop
37
- startWatcherLoop();
38
-
39
- // Auto-start tunnel if configured
40
47
  const settings = loadSettings();
41
48
  if (settings.tunnelAutoStart) {
42
49
  startTunnel().then(result => {
@@ -54,6 +61,23 @@ export function restartTelegramBot() {
54
61
  startTelegramBot();
55
62
  }
56
63
 
64
+ let telegramChild: ReturnType<typeof spawn> | null = null;
65
+
66
+ function startTelegramProcess() {
67
+ if (telegramChild) return;
68
+ const settings = loadSettings();
69
+ if (!settings.telegramBotToken || !settings.telegramChatId) return;
70
+
71
+ const script = join(process.cwd(), 'lib', 'telegram-standalone.ts');
72
+ telegramChild = spawn('npx', ['tsx', script], {
73
+ stdio: ['ignore', 'inherit', 'inherit'],
74
+ env: { ...process.env, PORT: String(process.env.PORT || 3000) },
75
+ detached: false,
76
+ });
77
+ telegramChild.on('exit', () => { telegramChild = null; });
78
+ console.log('[telegram] Started standalone (pid:', telegramChild.pid, ')');
79
+ }
80
+
57
81
  let terminalChild: ReturnType<typeof spawn> | null = null;
58
82
 
59
83
  function startTerminalProcess() {
@@ -61,11 +85,9 @@ function startTerminalProcess() {
61
85
 
62
86
  const termPort = Number(process.env.TERMINAL_PORT) || 3001;
63
87
 
64
- // Check if port is already in use — kill stale process if needed
65
88
  const net = require('node:net');
66
89
  const tester = net.createServer();
67
90
  tester.once('error', () => {
68
- // Port in use — terminal server already running, reuse it
69
91
  console.log(`[terminal] Port ${termPort} already in use, reusing existing`);
70
92
  });
71
93
  tester.once('listening', () => {
@@ -16,11 +16,11 @@ import { startTunnel, stopTunnel, getTunnelStatus } from './cloudflared';
16
16
  import { getPassword } from './password';
17
17
  import type { Task, TaskLogEntry } from '@/src/types';
18
18
 
19
- // Prevent duplicate polling and state loss across hot-reloads
19
+ // Persist state across hot-reloads
20
20
  const globalKey = Symbol.for('mw-telegram-state');
21
21
  const g = globalThis as any;
22
- if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0, taskListenerAttached: false, pollActive: false, processedMsgIds: new Set<number>(), pollGeneration: 0 };
23
- const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number; taskListenerAttached: boolean; pollActive: boolean; processedMsgIds: Set<number>; pollGeneration: number } = g[globalKey];
22
+ if (!g[globalKey]) g[globalKey] = { taskListenerAttached: false, processedMsgIds: new Set<number>() };
23
+ const botState: { taskListenerAttached: boolean; processedMsgIds: Set<number> } = g[globalKey];
24
24
 
25
25
  // Track which Telegram message maps to which task (for reply-based interaction)
26
26
  const taskMessageMap = new Map<number, string>(); // messageId → taskId
@@ -45,31 +45,22 @@ const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof
45
45
 
46
46
  // ─── Start/Stop ──────────────────────────────────────────────
47
47
 
48
+ // telegram-standalone process is managed by forge-server.mjs
49
+
48
50
  export function startTelegramBot() {
49
51
  const settings = loadSettings();
50
52
  if (!settings.telegramBotToken || !settings.telegramChatId) return;
51
53
 
52
- // Kill any existing poll loop (handles hot-reload creating duplicates)
53
- if (botState.polling) {
54
- botState.pollGeneration++;
55
- if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
56
- botState.pollActive = false;
57
- }
58
-
59
- botState.polling = true;
60
- console.log('[telegram] Bot started');
61
-
62
54
  // Set bot command menu
63
55
  setBotCommands(settings.telegramBotToken);
64
56
 
65
- // Listen for task events → stream to Telegram (only once)
57
+ // Listen for task events → stream to Telegram (only once per worker)
66
58
  if (!botState.taskListenerAttached) {
67
59
  botState.taskListenerAttached = true;
68
60
  onTaskEvent((taskId, event, data) => {
69
61
  const settings = loadSettings();
70
62
  if (!settings.telegramBotToken || !settings.telegramChatId) return;
71
63
 
72
- // Skip pipeline tasks — they have their own notification
73
64
  try {
74
65
  const { pipelineTaskIds } = require('./pipeline');
75
66
  if (pipelineTaskIds.has(taskId)) return;
@@ -85,75 +76,20 @@ export function startTelegramBot() {
85
76
  });
86
77
  }
87
78
 
88
- // Skip stale updates on startup set offset to -1 to get only new messages
89
- if (botState.lastUpdateId === 0) {
90
- fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=-1`)
91
- .then(r => r.json())
92
- .then(data => {
93
- if (data.ok && data.result?.length > 0) {
94
- botState.lastUpdateId = data.result[data.result.length - 1].update_id;
95
- }
96
- poll();
97
- })
98
- .catch(() => poll());
99
- } else {
100
- poll();
101
- }
79
+ // Note: telegram-standalone process is started by forge-server.mjs, not here.
80
+ // This function only sets up the task event listener and bot commands.
102
81
  }
103
82
 
104
83
  export function stopTelegramBot() {
105
- botState.polling = false;
106
- botState.pollActive = false;
107
- if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
108
- }
109
-
110
- // ─── Polling ─────────────────────────────────────────────────
111
-
112
- function schedulePoll(delay: number = 1000) {
113
- if (botState.pollTimer) clearTimeout(botState.pollTimer);
114
- botState.pollTimer = setTimeout(poll, delay);
115
- }
116
-
117
- async function poll() {
118
- const myGeneration = botState.pollGeneration;
119
-
120
- // Prevent concurrent polls
121
- if (!botState.polling || botState.pollActive) return;
122
- botState.pollActive = true;
123
-
124
- try {
125
- const settings = loadSettings();
126
- const controller = new AbortController();
127
- const timeout = setTimeout(() => controller.abort(), 35000);
128
-
129
- const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${botState.lastUpdateId + 1}&timeout=30`;
130
- const res = await fetch(url, { signal: controller.signal });
131
- clearTimeout(timeout);
132
-
133
- const data = await res.json();
134
-
135
- if (data.ok && data.result && data.result.length > 0) {
136
- console.log(`[telegram] Poll got ${data.result.length} updates, lastId=${botState.lastUpdateId}`);
137
- for (const update of data.result) {
138
- if (update.update_id <= botState.lastUpdateId) continue;
139
- botState.lastUpdateId = update.update_id;
140
- if (update.message?.text) {
141
- console.log(`[telegram] Processing msg ${update.message.message_id}: ${update.message.text.slice(0, 30)}`);
142
- await handleMessage(update.message);
143
- }
144
- }
145
- }
146
- } catch {
147
- // Network errors during sleep/wake — silent
148
- }
149
-
150
- botState.pollActive = false;
151
- // Only continue polling if this is still the current generation
152
- if (botState.polling && myGeneration === botState.pollGeneration) schedulePoll(1000);
84
+ // telegram-standalone is managed by forge-server.mjs
85
+ // This is a no-op now, kept for API compatibility
153
86
  }
154
87
 
155
88
  // ─── Message Handler ─────────────────────────────────────────
156
89
 
90
+ // Exported for API route — called by telegram-standalone via /api/telegram
91
+ export async function handleTelegramMessage(msg: any) { return handleMessage(msg); }
92
+
157
93
  async function handleMessage(msg: any) {
158
94
  const chatId = msg.chat.id;
159
95
 
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Standalone Telegram bot process.
4
+ * Runs as a single process — no duplication from Next.js workers.
5
+ */
6
+
7
+ import { loadSettings } from './settings';
8
+ import { getPassword } from './password';
9
+
10
+ const settings = loadSettings();
11
+ if (!settings.telegramBotToken || !settings.telegramChatId) {
12
+ console.log('[telegram] No token or chatId configured, exiting');
13
+ process.exit(0);
14
+ }
15
+
16
+ const TOKEN = settings.telegramBotToken;
17
+ const ALLOWED_IDS = settings.telegramChatId.split(',').map(s => s.trim()).filter(Boolean);
18
+ let lastUpdateId = 0;
19
+ let polling = true;
20
+ const processedMsgIds = new Set<number>();
21
+
22
+ // Skip stale messages on startup
23
+ async function init() {
24
+ try {
25
+ const res = await fetch(`https://api.telegram.org/bot${TOKEN}/getUpdates?offset=-1`);
26
+ const data = await res.json();
27
+ if (data.ok && data.result?.length > 0) {
28
+ lastUpdateId = data.result[data.result.length - 1].update_id;
29
+ }
30
+ } catch {}
31
+ console.log('[telegram] Bot started (standalone)');
32
+ poll();
33
+ }
34
+
35
+ async function poll() {
36
+ if (!polling) return;
37
+
38
+ try {
39
+ const controller = new AbortController();
40
+ const timeout = setTimeout(() => controller.abort(), 35000);
41
+
42
+ const res = await fetch(
43
+ `https://api.telegram.org/bot${TOKEN}/getUpdates?offset=${lastUpdateId + 1}&timeout=30`,
44
+ { signal: controller.signal }
45
+ );
46
+ clearTimeout(timeout);
47
+
48
+ const data = await res.json();
49
+ if (data.ok && data.result) {
50
+ for (const update of data.result) {
51
+ if (update.update_id <= lastUpdateId) continue;
52
+ lastUpdateId = update.update_id;
53
+
54
+ if (update.message?.text) {
55
+ const msgId = update.message.message_id;
56
+ if (processedMsgIds.has(msgId)) continue;
57
+ processedMsgIds.add(msgId);
58
+ if (processedMsgIds.size > 200) {
59
+ const oldest = [...processedMsgIds].slice(0, 100);
60
+ oldest.forEach(id => processedMsgIds.delete(id));
61
+ }
62
+
63
+ // Forward to Next.js API for processing
64
+ try {
65
+ await fetch(`http://localhost:${process.env.PORT || 3000}/api/telegram`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json', 'x-telegram-secret': TOKEN },
68
+ body: JSON.stringify(update.message),
69
+ });
70
+ } catch {}
71
+ }
72
+ }
73
+ }
74
+ } catch {
75
+ // Network error — silent retry
76
+ }
77
+
78
+ setTimeout(poll, 1000);
79
+ }
80
+
81
+ process.on('SIGTERM', () => { polling = false; process.exit(0); });
82
+ process.on('SIGINT', () => { polling = false; process.exit(0); });
83
+
84
+ init();
package/middleware.ts CHANGED
@@ -7,6 +7,7 @@ export function middleware(req: NextRequest) {
7
7
  if (
8
8
  pathname.startsWith('/login') ||
9
9
  pathname.startsWith('/api/auth') ||
10
+ pathname.startsWith('/api/telegram') ||
10
11
  pathname.startsWith('/_next') ||
11
12
  pathname === '/favicon.ico'
12
13
  ) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
package/start.sh ADDED
@@ -0,0 +1,21 @@
1
+ #!/bin/bash
2
+ # start.sh — Start Forge locally (kill old processes, build, start)
3
+ #
4
+ # Usage:
5
+ # ./start.sh # production mode
6
+ # ./start.sh dev # dev mode (hot-reload)
7
+
8
+ # Kill all old forge processes
9
+ pkill -f 'telegram-standalone' 2>/dev/null
10
+ pkill -f 'terminal-standalone' 2>/dev/null
11
+ pkill -f 'cloudflared tunnel' 2>/dev/null
12
+ pkill -f 'next-server' 2>/dev/null
13
+ pkill -f 'next start' 2>/dev/null
14
+ pkill -f 'next dev' 2>/dev/null
15
+ sleep 1
16
+
17
+ if [ "$1" = "dev" ]; then
18
+ pnpm dev
19
+ else
20
+ pnpm build && pnpm start
21
+ fi