@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.
- package/app/api/telegram/route.ts +23 -0
- package/app/api/tunnel/route.ts +0 -2
- package/app/login/page.tsx +7 -3
- package/bin/forge-server.mjs +78 -5
- package/components/SettingsModal.tsx +33 -6
- package/dev-test.sh +1 -1
- package/lib/cloudflared.ts +63 -4
- package/lib/init.ts +38 -16
- package/lib/telegram-bot.ts +13 -77
- package/lib/telegram-standalone.ts +84 -0
- package/middleware.ts +1 -0
- package/package.json +1 -1
- package/start.sh +21 -0
|
@@ -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
|
+
}
|
package/app/api/tunnel/route.ts
CHANGED
|
@@ -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
|
|
package/app/login/page.tsx
CHANGED
|
@@ -11,9 +11,13 @@ export default function LoginPage() {
|
|
|
11
11
|
e.preventDefault();
|
|
12
12
|
const result = await signIn('credentials', {
|
|
13
13
|
password,
|
|
14
|
-
|
|
15
|
-
}) as { error?: string } | undefined;
|
|
16
|
-
if (result?.error)
|
|
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 (
|
package/bin/forge-server.mjs
CHANGED
|
@@ -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
|
-
<
|
|
243
|
+
<SecretInput
|
|
217
244
|
value={settings.telegramBotToken}
|
|
218
|
-
onChange={
|
|
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
|
-
<
|
|
476
|
+
<SecretInput
|
|
450
477
|
value={settings.telegramTunnelPassword}
|
|
451
|
-
onChange={
|
|
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
|
package/lib/cloudflared.ts
CHANGED
|
@@ -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:
|
|
215
|
-
url:
|
|
216
|
-
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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', () => {
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -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
|
-
//
|
|
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] = {
|
|
23
|
-
const botState: {
|
|
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
|
-
//
|
|
89
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
package/package.json
CHANGED
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
|