mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import type {
|
|
AppSettings,
|
|
AppSettingsUpdate,
|
|
Channel,
|
|
ChannelDetail,
|
|
CommandResponse,
|
|
Contact,
|
|
ContactAnalytics,
|
|
ContactAdvertPathSummary,
|
|
FanoutConfig,
|
|
Favorite,
|
|
HealthStatus,
|
|
MaintenanceResult,
|
|
Message,
|
|
MessagesAroundResponse,
|
|
MigratePreferencesRequest,
|
|
MigratePreferencesResponse,
|
|
RawPacket,
|
|
RadioAdvertMode,
|
|
RadioConfig,
|
|
RadioConfigUpdate,
|
|
RadioDiscoveryResponse,
|
|
RadioDiscoveryTarget,
|
|
PathDiscoveryResponse,
|
|
ResendChannelMessageResponse,
|
|
RepeaterAclResponse,
|
|
RepeaterAdvertIntervalsResponse,
|
|
RepeaterLoginResponse,
|
|
RepeaterLppTelemetryResponse,
|
|
RepeaterNeighborsResponse,
|
|
RepeaterNodeInfoResponse,
|
|
RepeaterOwnerInfoResponse,
|
|
RepeaterRadioSettingsResponse,
|
|
RepeaterStatusResponse,
|
|
StatisticsResponse,
|
|
TraceResponse,
|
|
UnreadCounts,
|
|
} from './types';
|
|
|
|
const API_BASE = '/api';
|
|
|
|
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
|
const hasBody = options?.body !== undefined;
|
|
const res = await fetch(`${API_BASE}${url}`, {
|
|
...options,
|
|
headers: {
|
|
...(hasBody && { 'Content-Type': 'application/json' }),
|
|
...options?.headers,
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
// FastAPI returns errors as {"detail": "message"}, extract the message
|
|
let errorMessage = errorText || res.statusText;
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
if (errorJson.detail) {
|
|
errorMessage = errorJson.detail;
|
|
}
|
|
} catch {
|
|
// Not JSON, use raw text
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/** Check if an error is an AbortError (request was cancelled) */
|
|
export function isAbortError(err: unknown): boolean {
|
|
// DOMException is thrown by fetch when aborted, and it's not an Error subclass
|
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
return true;
|
|
}
|
|
// Also check for Error with AbortError name (for compatibility)
|
|
return err instanceof Error && err.name === 'AbortError';
|
|
}
|
|
|
|
interface DecryptResult {
|
|
started: boolean;
|
|
total_packets: number;
|
|
message: string;
|
|
}
|
|
|
|
export const api = {
|
|
// Health
|
|
getHealth: () => fetchJson<HealthStatus>('/health'),
|
|
|
|
// Radio config
|
|
getRadioConfig: () => fetchJson<RadioConfig>('/radio/config'),
|
|
updateRadioConfig: (config: RadioConfigUpdate) =>
|
|
fetchJson<RadioConfig>('/radio/config', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(config),
|
|
}),
|
|
setPrivateKey: (privateKey: string) =>
|
|
fetchJson<{ status: string }>('/radio/private-key', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ private_key: privateKey }),
|
|
}),
|
|
sendAdvertisement: (mode: RadioAdvertMode = 'flood') =>
|
|
fetchJson<{ status: string }>('/radio/advertise', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ mode }),
|
|
}),
|
|
discoverMesh: (target: RadioDiscoveryTarget) =>
|
|
fetchJson<RadioDiscoveryResponse>('/radio/discover', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ target }),
|
|
}),
|
|
rebootRadio: () =>
|
|
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
|
method: 'POST',
|
|
}),
|
|
disconnectRadio: () =>
|
|
fetchJson<{ status: string; message: string; connected: boolean; paused: boolean }>(
|
|
'/radio/disconnect',
|
|
{
|
|
method: 'POST',
|
|
}
|
|
),
|
|
reconnectRadio: () =>
|
|
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// Contacts
|
|
getContacts: (limit = 100, offset = 0) =>
|
|
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
|
|
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
|
|
fetchJson<ContactAdvertPathSummary[]>(
|
|
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
|
),
|
|
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
|
const searchParams = new URLSearchParams();
|
|
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
|
if (params.name) searchParams.set('name', params.name);
|
|
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
|
},
|
|
deleteContact: (publicKey: string) =>
|
|
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
|
fetchJson<Contact>('/contacts', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
|
|
}),
|
|
markContactRead: (publicKey: string) =>
|
|
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
|
method: 'POST',
|
|
}),
|
|
sendRepeaterCommand: (publicKey: string, command: string) =>
|
|
fetchJson<CommandResponse>(`/contacts/${publicKey}/command`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ command }),
|
|
}),
|
|
requestTrace: (publicKey: string) =>
|
|
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
|
|
method: 'POST',
|
|
}),
|
|
requestPathDiscovery: (publicKey: string) =>
|
|
fetchJson<PathDiscoveryResponse>(`/contacts/${publicKey}/path-discovery`, {
|
|
method: 'POST',
|
|
}),
|
|
setContactRoutingOverride: (publicKey: string, route: string) =>
|
|
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ route }),
|
|
}),
|
|
|
|
// Channels
|
|
getChannels: () => fetchJson<Channel[]>('/channels'),
|
|
createChannel: (name: string, key?: string) =>
|
|
fetchJson<Channel>('/channels', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, key }),
|
|
}),
|
|
deleteChannel: (key: string) =>
|
|
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
|
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
|
markChannelRead: (key: string) =>
|
|
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
|
|
method: 'POST',
|
|
}),
|
|
setChannelFloodScopeOverride: (key: string, floodScopeOverride: string) =>
|
|
fetchJson<Channel>(`/channels/${key}/flood-scope-override`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
|
}),
|
|
|
|
// Messages
|
|
getMessages: (
|
|
params?: {
|
|
limit?: number;
|
|
offset?: number;
|
|
type?: 'PRIV' | 'CHAN';
|
|
conversation_key?: string;
|
|
before?: number;
|
|
before_id?: number;
|
|
after?: number;
|
|
after_id?: number;
|
|
q?: string;
|
|
},
|
|
signal?: AbortSignal
|
|
) => {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.limit !== undefined) searchParams.set('limit', params.limit.toString());
|
|
if (params?.offset !== undefined) searchParams.set('offset', params.offset.toString());
|
|
if (params?.type) searchParams.set('type', params.type);
|
|
if (params?.conversation_key) searchParams.set('conversation_key', params.conversation_key);
|
|
if (params?.before !== undefined) searchParams.set('before', params.before.toString());
|
|
if (params?.before_id !== undefined) searchParams.set('before_id', params.before_id.toString());
|
|
if (params?.after !== undefined) searchParams.set('after', params.after.toString());
|
|
if (params?.after_id !== undefined) searchParams.set('after_id', params.after_id.toString());
|
|
if (params?.q) searchParams.set('q', params.q);
|
|
const query = searchParams.toString();
|
|
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`, { signal });
|
|
},
|
|
getMessagesAround: (
|
|
messageId: number,
|
|
type?: 'PRIV' | 'CHAN',
|
|
conversationKey?: string,
|
|
signal?: AbortSignal
|
|
) => {
|
|
const searchParams = new URLSearchParams();
|
|
if (type) searchParams.set('type', type);
|
|
if (conversationKey) searchParams.set('conversation_key', conversationKey);
|
|
const query = searchParams.toString();
|
|
return fetchJson<MessagesAroundResponse>(
|
|
`/messages/around/${messageId}${query ? `?${query}` : ''}`,
|
|
{ signal }
|
|
);
|
|
},
|
|
sendDirectMessage: (destination: string, text: string) =>
|
|
fetchJson<Message>('/messages/direct', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ destination, text }),
|
|
}),
|
|
sendChannelMessage: (channelKey: string, text: string) =>
|
|
fetchJson<Message>('/messages/channel', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ channel_key: channelKey, text }),
|
|
}),
|
|
resendChannelMessage: (messageId: number, newTimestamp?: boolean) =>
|
|
fetchJson<ResendChannelMessageResponse>(
|
|
`/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Packets
|
|
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
|
|
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
|
decryptHistoricalPackets: (params: {
|
|
key_type: 'channel' | 'contact';
|
|
channel_key?: string;
|
|
channel_name?: string;
|
|
}) =>
|
|
fetchJson<DecryptResult>('/packets/decrypt/historical', {
|
|
method: 'POST',
|
|
body: JSON.stringify(params),
|
|
}),
|
|
runMaintenance: (options: { pruneUndecryptedDays?: number; purgeLinkedRawPackets?: boolean }) =>
|
|
fetchJson<MaintenanceResult>('/packets/maintenance', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
...(options.pruneUndecryptedDays !== undefined && {
|
|
prune_undecrypted_days: options.pruneUndecryptedDays,
|
|
}),
|
|
...(options.purgeLinkedRawPackets !== undefined && {
|
|
purge_linked_raw_packets: options.purgeLinkedRawPackets,
|
|
}),
|
|
}),
|
|
}),
|
|
|
|
// Read State
|
|
getUnreads: () => fetchJson<UnreadCounts>('/read-state/unreads'),
|
|
markAllRead: () =>
|
|
fetchJson<{ status: string; timestamp: number }>('/read-state/mark-all-read', {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// App Settings
|
|
getSettings: () => fetchJson<AppSettings>('/settings'),
|
|
updateSettings: (settings: AppSettingsUpdate) =>
|
|
fetchJson<AppSettings>('/settings', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(settings),
|
|
}),
|
|
|
|
// Block lists
|
|
toggleBlockedKey: (key: string) =>
|
|
fetchJson<AppSettings>('/settings/blocked-keys/toggle', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ key }),
|
|
}),
|
|
toggleBlockedName: (name: string) =>
|
|
fetchJson<AppSettings>('/settings/blocked-names/toggle', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
|
|
// Favorites
|
|
toggleFavorite: (type: Favorite['type'], id: string) =>
|
|
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ type, id }),
|
|
}),
|
|
|
|
// Preferences migration (one-time, from localStorage to database)
|
|
migratePreferences: (request: MigratePreferencesRequest) =>
|
|
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
|
|
method: 'POST',
|
|
body: JSON.stringify(request),
|
|
}),
|
|
|
|
// Fanout
|
|
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
|
createFanoutConfig: (config: {
|
|
type: string;
|
|
name: string;
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
enabled?: boolean;
|
|
}) =>
|
|
fetchJson<FanoutConfig>('/fanout', {
|
|
method: 'POST',
|
|
body: JSON.stringify(config),
|
|
}),
|
|
updateFanoutConfig: (
|
|
id: string,
|
|
update: {
|
|
name?: string;
|
|
config?: Record<string, unknown>;
|
|
scope?: Record<string, unknown>;
|
|
enabled?: boolean;
|
|
}
|
|
) =>
|
|
fetchJson<FanoutConfig>(`/fanout/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(update),
|
|
}),
|
|
deleteFanoutConfig: (id: string) =>
|
|
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
disableBotsUntilRestart: () =>
|
|
fetchJson<{
|
|
status: string;
|
|
bots_disabled: boolean;
|
|
bots_disabled_source: 'env' | 'until_restart';
|
|
}>('/fanout/bots/disable-until-restart', {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// Statistics
|
|
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
|
|
|
// Granular repeater endpoints
|
|
repeaterLogin: (publicKey: string, password: string) =>
|
|
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/repeater/login`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
repeaterStatus: (publicKey: string) =>
|
|
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
|
|
method: 'POST',
|
|
}),
|
|
repeaterNeighbors: (publicKey: string) =>
|
|
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
|
|
method: 'POST',
|
|
}),
|
|
repeaterNodeInfo: (publicKey: string) =>
|
|
fetchJson<RepeaterNodeInfoResponse>(`/contacts/${publicKey}/repeater/node-info`, {
|
|
method: 'POST',
|
|
}),
|
|
repeaterAcl: (publicKey: string) =>
|
|
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
|
|
method: 'POST',
|
|
}),
|
|
repeaterRadioSettings: (publicKey: string) =>
|
|
fetchJson<RepeaterRadioSettingsResponse>(`/contacts/${publicKey}/repeater/radio-settings`, {
|
|
method: 'POST',
|
|
}),
|
|
repeaterAdvertIntervals: (publicKey: string) =>
|
|
fetchJson<RepeaterAdvertIntervalsResponse>(`/contacts/${publicKey}/repeater/advert-intervals`, {
|
|
method: 'POST',
|
|
}),
|
|
repeaterOwnerInfo: (publicKey: string) =>
|
|
fetchJson<RepeaterOwnerInfoResponse>(`/contacts/${publicKey}/repeater/owner-info`, {
|
|
method: 'POST',
|
|
}),
|
|
repeaterLppTelemetry: (publicKey: string) =>
|
|
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
|
method: 'POST',
|
|
}),
|
|
roomLogin: (publicKey: string, password: string) =>
|
|
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
roomStatus: (publicKey: string) =>
|
|
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/room/status`, {
|
|
method: 'POST',
|
|
}),
|
|
roomAcl: (publicKey: string) =>
|
|
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/room/acl`, {
|
|
method: 'POST',
|
|
}),
|
|
roomLppTelemetry: (publicKey: string) =>
|
|
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
|
|
method: 'POST',
|
|
}),
|
|
};
|