feat: NetUP via agent — proxy HTTP requests through Windows workstation
- Migration 044: add connection_type + agent_workstation_id to netup_settings - Backend: netupReqViaCfg routes requests via agent WebSocket when connection_type=agent - Agent: add netup_request command handler (proxies HTTP to local NetUP server) - TvWelcomePage: add connection mode toggle (direct / via agent) + workstation selector - Hint changes: URL placeholder shows local IP when agent mode selected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { sendCommandAndWait } from '../agent-ws'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
@@ -53,8 +54,8 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
// ── Вспомогательная функция: HTTP запрос к NetUP MW ──────────────────────
|
||||
async function netupReq(
|
||||
// ── HTTP запрос к NetUP MW напрямую ──────────────────────────────────────
|
||||
async function netupReqDirect(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
@@ -68,20 +69,53 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Basic ${cred}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
|
||||
let data: unknown = null
|
||||
try { data = await res.json() } catch { /* empty response */ }
|
||||
|
||||
return { ok: res.ok, status: res.status, data }
|
||||
}
|
||||
|
||||
// ── HTTP запрос через агент ───────────────────────────────────────────────
|
||||
async function netupReqViaAgent(
|
||||
workstationId: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||
const base = serverUrl.replace(/\/$/, '')
|
||||
const url = `${base}/mw/api${path}`
|
||||
const cred = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
|
||||
const result = await sendCommandAndWait(workstationId, {
|
||||
type: 'netup_request',
|
||||
method,
|
||||
url,
|
||||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: body ?? null,
|
||||
}, 10000) as { ok: boolean; status: number; data: unknown }
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Роутинг: прямой или через агент ──────────────────────────────────────
|
||||
async function netupReq(
|
||||
cfg: { server_url: string; username: string; password: string; connection_type?: string; agent_workstation_id?: string | null },
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||
return netupReqViaAgent(cfg.agent_workstation_id, cfg.server_url, cfg.username, cfg.password, method, path, body)
|
||||
}
|
||||
return netupReqDirect(cfg.server_url, cfg.username, cfg.password, method, path, body)
|
||||
}
|
||||
|
||||
// ── GET /api/hotels/:slug/netup/settings ────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/netup/settings',
|
||||
@@ -95,15 +129,15 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT server_url, username, password, default_language, enabled, tl_token FROM netup_settings WHERE hotel_id = $1',
|
||||
'SELECT server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '' }
|
||||
return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '', connection_type: 'direct', agent_workstation_id: null }
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/netup/settings ───────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string } }>(
|
||||
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string; connection_type?: string; agent_workstation_id?: string | null } }>(
|
||||
'/api/hotels/:slug/netup/settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
@@ -115,20 +149,23 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { server_url, username, password, default_language, enabled, tl_token } = request.body
|
||||
const { server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id } = request.body
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||
server_url = EXCLUDED.server_url,
|
||||
username = EXCLUDED.username,
|
||||
password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END,
|
||||
default_language = EXCLUDED.default_language,
|
||||
enabled = EXCLUDED.enabled,
|
||||
tl_token = EXCLUDED.tl_token,
|
||||
updated_at = NOW()`,
|
||||
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? ''],
|
||||
server_url = EXCLUDED.server_url,
|
||||
username = EXCLUDED.username,
|
||||
password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END,
|
||||
default_language = EXCLUDED.default_language,
|
||||
enabled = EXCLUDED.enabled,
|
||||
tl_token = EXCLUDED.tl_token,
|
||||
connection_type = EXCLUDED.connection_type,
|
||||
agent_workstation_id = EXCLUDED.agent_workstation_id,
|
||||
updated_at = NOW()`,
|
||||
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? '',
|
||||
connection_type ?? 'direct', agent_workstation_id ?? null],
|
||||
)
|
||||
return { ok: true }
|
||||
},
|
||||
@@ -147,14 +184,14 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1',
|
||||
'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
const cfg = rows[0]
|
||||
if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' })
|
||||
|
||||
try {
|
||||
const result = await netupReq(cfg.server_url, cfg.username, cfg.password, 'GET', '/hotel-room')
|
||||
const result = await netupReq(cfg, 'GET', '/hotel-room')
|
||||
if (result.ok) return { ok: true, message: 'Подключение успешно' }
|
||||
return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` })
|
||||
} catch (err) {
|
||||
@@ -240,7 +277,7 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// Get NetUP settings
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' })
|
||||
@@ -257,7 +294,7 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
try {
|
||||
const result = await netupReq(
|
||||
cfg.server_url, cfg.username, cfg.password,
|
||||
cfg,
|
||||
'POST', '/system-message',
|
||||
{
|
||||
name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`,
|
||||
@@ -358,13 +395,45 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function netupReqViaCfg(
|
||||
cfg: { server_url: string; username: string; password: string; connection_type: string; agent_workstation_id: string | null },
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const url = `${base}/mw/api${path}`
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
return sendCommandAndWait(cfg.agent_workstation_id, {
|
||||
type: 'netup_request',
|
||||
method,
|
||||
url,
|
||||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: body ?? null,
|
||||
}, 10000) as Promise<{ ok: boolean; status: number; data: unknown }>
|
||||
}
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const url = `${base}/mw/api${path}`
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
let data: unknown = null
|
||||
try { data = await res.json() } catch { /* empty */ }
|
||||
return { ok: res.ok, status: res.status, data }
|
||||
}
|
||||
|
||||
async function getNetupCfg(hotelId: string, needEnabled = true) {
|
||||
const cond = needEnabled ? 'AND enabled = true' : ''
|
||||
const { rows: [cfg] } = await db.query(
|
||||
`SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 ${cond}`,
|
||||
`SELECT server_url, username, password, default_language, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 ${cond}`,
|
||||
[hotelId],
|
||||
)
|
||||
return cfg as { server_url: string; username: string; password: string; default_language: string } | undefined
|
||||
return cfg as { server_url: string; username: string; password: string; default_language: string; connection_type: string; agent_workstation_id: string | null } | undefined
|
||||
}
|
||||
|
||||
async function getMapping(hotelId: string, roomId: string) {
|
||||
@@ -396,31 +465,41 @@ async function notifyNetupCheckinInternal(
|
||||
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
|
||||
// NetUP expects reservation_id as a numeric string (parses with strconv.Atoi)
|
||||
// Convert UUID to a stable numeric string by taking first 8 hex chars → decimal
|
||||
const resIdStr = String(parseInt(reservationId.replace(/-/g, '').slice(0, 8), 16) % 100000000)
|
||||
const requestBody = { reservation_id: resIdStr, name: guestName, language: language ?? cfg.default_language }
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
const responseText = await res.text().catch(() => '')
|
||||
let ok: boolean, status: number
|
||||
let responseText = ''
|
||||
|
||||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||
const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, requestBody)
|
||||
ok = result.ok; status = result.status
|
||||
responseText = result.data ? JSON.stringify(result.data) : ''
|
||||
} else {
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
ok = res.ok; status = res.status
|
||||
responseText = await res.text().catch(() => '')
|
||||
}
|
||||
|
||||
const event: PushEvent = {
|
||||
ts: new Date().toISOString(), action: 'check-in', hotelId,
|
||||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||||
requestBody,
|
||||
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
|
||||
status: ok ? 'ok' : 'error', httpStatus: status,
|
||||
responseBody: responseText || undefined,
|
||||
}
|
||||
if (!res.ok) event.error = `HTTP ${res.status}`
|
||||
if (!ok) event.error = `HTTP ${status}`
|
||||
recordPush(event)
|
||||
return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}: ${responseText}` }
|
||||
return ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${status}: ${responseText}` }
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: 'error', error })
|
||||
@@ -451,21 +530,31 @@ export async function notifyNetupCheckout(hotelId: string, roomId: string) {
|
||||
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}` },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
const responseText = await res.text().catch(() => '')
|
||||
let ok: boolean, status: number, responseText = ''
|
||||
|
||||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||
const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`)
|
||||
ok = result.ok; status = result.status
|
||||
responseText = result.data ? JSON.stringify(result.data) : ''
|
||||
} else {
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}` },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
ok = res.ok; status = res.status
|
||||
responseText = await res.text().catch(() => '')
|
||||
}
|
||||
|
||||
recordPush({
|
||||
ts: new Date().toISOString(), action: 'check-out', hotelId,
|
||||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||||
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
|
||||
status: ok ? 'ok' : 'error', httpStatus: status,
|
||||
responseBody: responseText || undefined,
|
||||
error: res.ok ? undefined : `HTTP ${res.status}`,
|
||||
error: ok ? undefined : `HTTP ${status}`,
|
||||
})
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||
|
||||
Reference in New Issue
Block a user