fix(FN-672): remove ntfy deep links and improve terminal error messages
- Remove ntfy deep link feature (ntfyDashboardHost setting) - Add changeset for terminal session error message improvements - Remove deep link logic from dashboard App and SettingsModal - Clean up notifier tests and implementation - Return specific error codes for terminal session failures
This commit is contained in:
5
.changeset/fix-terminal-error-messages.md
Normal file
5
.changeset/fix-terminal-error-messages.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix terminal session creation error messages to return specific, actionable error messages instead of generic "Max sessions" message. Now returns distinct error codes (max_sessions, invalid_shell, pty_load_failed, pty_spawn_failed) with appropriate HTTP status codes.
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@gsxdsm/fusion": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Add deep link support to ntfy notifications. Notifications now include a Click URL that opens the dashboard directly to the task. New global setting "Dashboard Hostname" configures the base URL for deep links.
|
|
||||||
@@ -535,10 +535,6 @@ export interface GlobalSettings {
|
|||||||
* no project can be auto-detected from the current directory.
|
* no project can be auto-detected from the current directory.
|
||||||
* Used for multi-project CLI workflows. */
|
* Used for multi-project CLI workflows. */
|
||||||
defaultProjectId?: string;
|
defaultProjectId?: string;
|
||||||
/** Dashboard hostname for ntfy.sh deep links. When set along with ntfyEnabled
|
|
||||||
* and ntfyTopic, notifications include a Click URL that opens the dashboard
|
|
||||||
* directly to the task. Example: "http://localhost:3000" or "https://fusion.example.com" */
|
|
||||||
ntfyDashboardHost?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -699,7 +695,6 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
|
|||||||
ntfyEnabled: false,
|
ntfyEnabled: false,
|
||||||
ntfyTopic: undefined,
|
ntfyTopic: undefined,
|
||||||
defaultProjectId: undefined,
|
defaultProjectId: undefined,
|
||||||
ntfyDashboardHost: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default values for project-level settings. */
|
/** Default values for project-level settings. */
|
||||||
@@ -759,7 +754,6 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
|||||||
"ntfyEnabled",
|
"ntfyEnabled",
|
||||||
"ntfyTopic",
|
"ntfyTopic",
|
||||||
"defaultProjectId",
|
"defaultProjectId",
|
||||||
"ntfyDashboardHost",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Keys that belong to the project settings scope. */
|
/** Keys that belong to the project settings scope. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
|
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels } from "./api";
|
||||||
import type { ModelInfo } from "./api";
|
import type { ModelInfo } from "./api";
|
||||||
import { Header } from "./components/Header";
|
import { Header } from "./components/Header";
|
||||||
import { Board } from "./components/Board";
|
import { Board } from "./components/Board";
|
||||||
@@ -109,27 +109,6 @@ function AppInner() {
|
|||||||
}, []);
|
}, []);
|
||||||
const { toasts, addToast, removeToast } = useToast();
|
const { toasts, addToast, removeToast } = useToast();
|
||||||
|
|
||||||
// Handle deep link to task on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const taskId = params.get("task");
|
|
||||||
if (!taskId) return;
|
|
||||||
|
|
||||||
// Clean URL immediately without reloading
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete("task");
|
|
||||||
window.history.replaceState({}, "", url.toString());
|
|
||||||
|
|
||||||
// Load and open the task directly
|
|
||||||
fetchTaskDetail(taskId)
|
|
||||||
.then((task) => {
|
|
||||||
handleDetailOpen(task);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
addToast(`Task ${taskId} not found`, "error");
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Persist view preference to localStorage
|
// Persist view preference to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("kb-dashboard-view", view);
|
localStorage.setItem("kb-dashboard-view", view);
|
||||||
|
|||||||
@@ -1378,37 +1378,6 @@ export function SettingsModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{form.ntfyEnabled && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
|
|
||||||
<input
|
|
||||||
id="ntfyDashboardHost"
|
|
||||||
type="text"
|
|
||||||
placeholder="http://localhost:3000"
|
|
||||||
value={form.ntfyDashboardHost || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<small>
|
|
||||||
Base URL for deep links in notifications. When set, clicking a notification
|
|
||||||
will open the dashboard directly to the task. Example: http://localhost:3000
|
|
||||||
or https://fusion.example.com
|
|
||||||
</small>
|
|
||||||
{form.ntfyDashboardHost && (
|
|
||||||
!/^https?:\/\/.+/.test(form.ntfyDashboardHost) ? (
|
|
||||||
<small className="field-error">
|
|
||||||
Must be a valid URL starting with http:// or https://
|
|
||||||
</small>
|
|
||||||
) : form.ntfyDashboardHost.includes("?") || form.ntfyDashboardHost.includes("#") ? (
|
|
||||||
<small className="field-error">
|
|
||||||
URL should not include query parameters or fragments
|
|
||||||
</small>
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "authentication":
|
case "authentication":
|
||||||
|
|||||||
@@ -275,168 +275,6 @@ describe("NtfyNotifier", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deep link (Click header)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
fetchMock.mockResolvedValue({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes Click header with task URL when ntfyDashboardHost is set", async () => {
|
|
||||||
store.setSettings({
|
|
||||||
ntfyEnabled: true,
|
|
||||||
ntfyTopic: "test-topic",
|
|
||||||
ntfyDashboardHost: "https://fusion.example.com",
|
|
||||||
});
|
|
||||||
notifier = new NtfyNotifier(store);
|
|
||||||
await notifier.start();
|
|
||||||
|
|
||||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"https://ntfy.sh/test-topic",
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
"Click": "https://fusion.example.com/?task=FN-001",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not include Click header when ntfyDashboardHost is not set", async () => {
|
|
||||||
store.setSettings({
|
|
||||||
ntfyEnabled: true,
|
|
||||||
ntfyTopic: "test-topic",
|
|
||||||
ntfyDashboardHost: undefined,
|
|
||||||
});
|
|
||||||
notifier = new NtfyNotifier(store);
|
|
||||||
await notifier.start();
|
|
||||||
|
|
||||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
const callArgs = fetchMock.mock.calls[0][1];
|
|
||||||
expect(callArgs.headers).not.toHaveProperty("Click");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles hostname with trailing slash correctly", async () => {
|
|
||||||
store.setSettings({
|
|
||||||
ntfyEnabled: true,
|
|
||||||
ntfyTopic: "test-topic",
|
|
||||||
ntfyDashboardHost: "http://localhost:3000/",
|
|
||||||
});
|
|
||||||
notifier = new NtfyNotifier(store);
|
|
||||||
await notifier.start();
|
|
||||||
|
|
||||||
store.triggerTaskMoved(createTask("FN-042", "Test Task"), "in-progress", "in-review");
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"https://ntfy.sh/test-topic",
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
"Click": "http://localhost:3000/?task=FN-042",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles hostname without trailing slash correctly", async () => {
|
|
||||||
store.setSettings({
|
|
||||||
ntfyEnabled: true,
|
|
||||||
ntfyTopic: "test-topic",
|
|
||||||
ntfyDashboardHost: "http://localhost:3000",
|
|
||||||
});
|
|
||||||
notifier = new NtfyNotifier(store);
|
|
||||||
await notifier.start();
|
|
||||||
|
|
||||||
store.triggerTaskMoved(createTask("FN-042", "Test Task"), "in-progress", "in-review");
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"https://ntfy.sh/test-topic",
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
"Click": "http://localhost:3000/?task=FN-042",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes Click header for failed task notifications when dashboard host is set", async () => {
|
|
||||||
store.setSettings({
|
|
||||||
ntfyEnabled: true,
|
|
||||||
ntfyTopic: "test-topic",
|
|
||||||
ntfyDashboardHost: "https://fusion.example.com",
|
|
||||||
});
|
|
||||||
notifier = new NtfyNotifier(store);
|
|
||||||
await notifier.start();
|
|
||||||
|
|
||||||
const failedTask = createTask("FN-001", "Test Task", "failed");
|
|
||||||
store.triggerTaskUpdated(failedTask);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"https://ntfy.sh/test-topic",
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
"Click": "https://fusion.example.com/?task=FN-001",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes Click header for merged task notifications when dashboard host is set", async () => {
|
|
||||||
store.setSettings({
|
|
||||||
ntfyEnabled: true,
|
|
||||||
ntfyTopic: "test-topic",
|
|
||||||
ntfyDashboardHost: "https://fusion.example.com",
|
|
||||||
});
|
|
||||||
notifier = new NtfyNotifier(store);
|
|
||||||
await notifier.start();
|
|
||||||
|
|
||||||
const mergeResult: MergeResult = {
|
|
||||||
task: createTask("FN-001", "Test Task"),
|
|
||||||
branch: "fusion/fn-001",
|
|
||||||
merged: true,
|
|
||||||
worktreeRemoved: true,
|
|
||||||
branchDeleted: true,
|
|
||||||
};
|
|
||||||
store.triggerTaskMerged(mergeResult);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"https://ntfy.sh/test-topic",
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
"Click": "https://fusion.example.com/?task=FN-001",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("encodes task IDs with special characters in Click URL", async () => {
|
|
||||||
store.setSettings({
|
|
||||||
ntfyEnabled: true,
|
|
||||||
ntfyTopic: "test-topic",
|
|
||||||
ntfyDashboardHost: "http://localhost:3000",
|
|
||||||
});
|
|
||||||
notifier = new NtfyNotifier(store);
|
|
||||||
await notifier.start();
|
|
||||||
|
|
||||||
store.triggerTaskMoved(createTask("FN-001/test", "Test Task"), "in-progress", "in-review");
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"https://ntfy.sh/test-topic",
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
"Click": "http://localhost:3000/?task=FN-001%2Ftest",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("runtime reconfiguration", () => {
|
describe("runtime reconfiguration", () => {
|
||||||
it("starts sending notifications when enabled at runtime", async () => {
|
it("starts sending notifications when enabled at runtime", async () => {
|
||||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ interface NtfyNotifierStore {
|
|||||||
interface NtfyConfig {
|
interface NtfyConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
topic: string | undefined;
|
topic: string | undefined;
|
||||||
dashboardHost: string | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Event types for notification deduplication */
|
/** Event types for notification deduplication */
|
||||||
@@ -51,7 +50,7 @@ type NotificationEventType = "in-review" | "merged" | "failed";
|
|||||||
* - Configurable notification events (hardcoded defaults)
|
* - Configurable notification events (hardcoded defaults)
|
||||||
*/
|
*/
|
||||||
export class NtfyNotifier {
|
export class NtfyNotifier {
|
||||||
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined };
|
private config: NtfyConfig = { enabled: false, topic: undefined };
|
||||||
private ntfyBaseUrl: string;
|
private ntfyBaseUrl: string;
|
||||||
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
|
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
|
||||||
private notifiedEvents: Set<string> = new Set();
|
private notifiedEvents: Set<string> = new Set();
|
||||||
@@ -117,14 +116,12 @@ export class NtfyNotifier {
|
|||||||
|
|
||||||
// Notify when task moves to in-review (completed work, ready for review)
|
// Notify when task moves to in-review (completed work, ready for review)
|
||||||
if (to === "in-review") {
|
if (to === "in-review") {
|
||||||
const clickUrl = this.buildTaskUrl(task.id);
|
|
||||||
this.maybeNotify(task.id, "in-review", () =>
|
this.maybeNotify(task.id, "in-review", () =>
|
||||||
this.sendNotification(
|
this.sendNotification(
|
||||||
this.config.topic!,
|
this.config.topic!,
|
||||||
`Task ${task.id} completed`,
|
`Task ${task.id} completed`,
|
||||||
`Task "${formatTaskIdentifier(task)}" is ready for review`,
|
`Task "${formatTaskIdentifier(task)}" is ready for review`,
|
||||||
"default",
|
"default",
|
||||||
clickUrl,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,14 +135,12 @@ export class NtfyNotifier {
|
|||||||
|
|
||||||
// Notify when task fails
|
// Notify when task fails
|
||||||
if (task.status === "failed") {
|
if (task.status === "failed") {
|
||||||
const clickUrl = this.buildTaskUrl(task.id);
|
|
||||||
this.maybeNotify(task.id, "failed", () =>
|
this.maybeNotify(task.id, "failed", () =>
|
||||||
this.sendNotification(
|
this.sendNotification(
|
||||||
this.config.topic!,
|
this.config.topic!,
|
||||||
`Task ${task.id} failed`,
|
`Task ${task.id} failed`,
|
||||||
`Task "${formatTaskIdentifier(task)}" has failed and needs attention`,
|
`Task "${formatTaskIdentifier(task)}" has failed and needs attention`,
|
||||||
"high",
|
"high",
|
||||||
clickUrl,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,14 +151,12 @@ export class NtfyNotifier {
|
|||||||
|
|
||||||
// Only notify on successful merges
|
// Only notify on successful merges
|
||||||
if (result.merged) {
|
if (result.merged) {
|
||||||
const clickUrl = this.buildTaskUrl(result.task.id);
|
|
||||||
this.maybeNotify(result.task.id, "merged", () =>
|
this.maybeNotify(result.task.id, "merged", () =>
|
||||||
this.sendNotification(
|
this.sendNotification(
|
||||||
this.config.topic!,
|
this.config.topic!,
|
||||||
`Task ${result.task.id} merged`,
|
`Task ${result.task.id} merged`,
|
||||||
`Task "${formatTaskIdentifier(result.task)}" has been merged to main`,
|
`Task "${formatTaskIdentifier(result.task)}" has been merged to main`,
|
||||||
"default",
|
"default",
|
||||||
clickUrl,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,8 +167,7 @@ export class NtfyNotifier {
|
|||||||
|
|
||||||
// Check if ntfy settings changed
|
// Check if ntfy settings changed
|
||||||
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
|
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||||
settings.ntfyTopic !== previous.ntfyTopic ||
|
settings.ntfyTopic !== previous.ntfyTopic) {
|
||||||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
|
|
||||||
const wasEnabled = this.config.enabled;
|
const wasEnabled = this.config.enabled;
|
||||||
this.loadConfig(settings);
|
this.loadConfig(settings);
|
||||||
|
|
||||||
@@ -185,8 +177,6 @@ export class NtfyNotifier {
|
|||||||
schedulerLog.log("NtfyNotifier disabled");
|
schedulerLog.log("NtfyNotifier disabled");
|
||||||
} else if (this.config.topic !== previous.ntfyTopic) {
|
} else if (this.config.topic !== previous.ntfyTopic) {
|
||||||
schedulerLog.log("NtfyNotifier topic updated");
|
schedulerLog.log("NtfyNotifier topic updated");
|
||||||
} else if (this.config.dashboardHost !== previous.ntfyDashboardHost) {
|
|
||||||
schedulerLog.log("NtfyNotifier dashboard host updated");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -195,23 +185,9 @@ export class NtfyNotifier {
|
|||||||
this.config = {
|
this.config = {
|
||||||
enabled: settings.ntfyEnabled ?? false,
|
enabled: settings.ntfyEnabled ?? false,
|
||||||
topic: settings.ntfyTopic,
|
topic: settings.ntfyTopic,
|
||||||
dashboardHost: settings.ntfyDashboardHost,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a dashboard URL for deep linking to a task.
|
|
||||||
* Returns undefined if dashboardHost is not configured.
|
|
||||||
*/
|
|
||||||
private buildTaskUrl(taskId: string): string | undefined {
|
|
||||||
if (!this.config.dashboardHost) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Strip trailing slash from hostname if present
|
|
||||||
const host = this.config.dashboardHost.replace(/\/$/, "");
|
|
||||||
return `${host}/?task=${encodeURIComponent(taskId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send notification if this (taskId, eventType) pair hasn't been notified before.
|
* Send notification if this (taskId, eventType) pair hasn't been notified before.
|
||||||
* This prevents duplicate notifications for the same event type per task.
|
* This prevents duplicate notifications for the same event type per task.
|
||||||
@@ -243,26 +219,18 @@ export class NtfyNotifier {
|
|||||||
title: string,
|
title: string,
|
||||||
message: string,
|
message: string,
|
||||||
priority: "low" | "default" | "high" | "urgent" = "default",
|
priority: "low" | "default" | "high" | "urgent" = "default",
|
||||||
clickUrl?: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = `${this.ntfyBaseUrl}/${topic}`;
|
const url = `${this.ntfyBaseUrl}/${topic}`;
|
||||||
const signal = this.abortController?.signal;
|
const signal = this.abortController?.signal;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
"Title": title,
|
"Title": title,
|
||||||
"Priority": priority,
|
"Priority": priority,
|
||||||
"Content-Type": "text/plain",
|
"Content-Type": "text/plain",
|
||||||
};
|
},
|
||||||
|
|
||||||
// Add Click header for deep linking if URL is provided
|
|
||||||
if (clickUrl) {
|
|
||||||
headers["Click"] = clickUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: message,
|
body: message,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user