feat(FN-4178): add room message notification delivery
This merge implements room message notifications across the system, adding a core room event type, wiring the notification dispatcher to room activity, and delivering notifications via ntfy and webhook providers with updated settings UI and API routes. Fusion-Task-Id: FN-4178
This commit is contained in:
5
.changeset/fn-4178-room-message-notification.md
Normal file
5
.changeset/fn-4178-room-message-notification.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a new `message:room` notification event for agent assistant replies posted in chat rooms. The event is enabled by default for ntfy notifications and can be tested or toggled from Settings → Notifications alongside the existing direct-message events.
|
||||||
@@ -43,11 +43,11 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
||||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||||
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
||||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). If you use a custom `ntfyEvents` list, this event must be present (or `ntfyEvents` must be unset so defaults apply) for agent-to-agent inbox notifications to send. |
|
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
||||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||||
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
||||||
|
|
||||||
In **Settings → Notifications**, use **Test message notification** to exercise the full mailbox-message dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
|
In **Settings → Notifications**, use **Test message notification** or **Send test room notification** to exercise the full message-dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
|
||||||
|
|
||||||
Fusion automatically falls back to ntfy's JSON publish format when a notification title or message contains non-Latin-1 characters, and truncates outgoing titles/messages to ntfy's documented size limits before sending.
|
Fusion automatically falls back to ntfy's JSON publish format when a notification title or message contains non-Latin-1 characters, and truncates outgoing titles/messages to ntfy's documented size limits before sending.
|
||||||
| `webhookUrl` | `string` | `undefined` | Webhook endpoint URL. Must be `http://` or `https://`. Part of legacy flat settings. |
|
| `webhookUrl` | `string` | `undefined` | Webhook endpoint URL. Must be `http://` or `https://`. Part of legacy flat settings. |
|
||||||
@@ -148,7 +148,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports
|
|||||||
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
||||||
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
||||||
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
|
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
|
||||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. |
|
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. |
|
||||||
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
||||||
|
|
||||||
Disable daily update checks globally:
|
Disable daily update checks globally:
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ describe("GlobalSettingsStore", () => {
|
|||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
"message:room",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe("NotificationDispatcher", () => {
|
|||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
"message:room",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
"message:room",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ export type NtfyNotificationEvent =
|
|||||||
| "fallback-used"
|
| "fallback-used"
|
||||||
| "memory-dreams-processed"
|
| "memory-dreams-processed"
|
||||||
| "message:agent-to-user"
|
| "message:agent-to-user"
|
||||||
| "message:agent-to-agent";
|
| "message:agent-to-agent"
|
||||||
|
| "message:room";
|
||||||
|
|
||||||
/** Known notification event types. Providers may support additional custom events. */
|
/** Known notification event types. Providers may support additional custom events. */
|
||||||
export const NOTIFICATION_EVENTS = [
|
export const NOTIFICATION_EVENTS = [
|
||||||
@@ -273,6 +274,7 @@ export const NOTIFICATION_EVENTS = [
|
|||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
"message:room",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Notification event type. Known events plus provider-specific custom events. */
|
/** Notification event type. Known events plus provider-specific custom events. */
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
|
|||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
"message:room",
|
||||||
];
|
];
|
||||||
|
|
||||||
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
|
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
|
||||||
@@ -262,6 +263,7 @@ const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: s
|
|||||||
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
|
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
|
||||||
{ event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" },
|
{ event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" },
|
||||||
{ event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" },
|
{ event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" },
|
||||||
|
{ event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Well-known experimental feature flags with display labels.
|
/** Well-known experimental feature flags with display labels.
|
||||||
@@ -1168,8 +1170,8 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
}, [addToast, loadAuthStatus]);
|
}, [addToast, loadAuthStatus]);
|
||||||
|
|
||||||
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook" | "ntfy-message") => {
|
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook" | "ntfy-message" | "ntfy-room") => {
|
||||||
if (providerId === "ntfy" || providerId === "ntfy-message") {
|
if (providerId === "ntfy" || providerId === "ntfy-message" || providerId === "ntfy-room") {
|
||||||
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
|
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1205,15 +1207,21 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
: providerId === "ntfy-message"
|
: providerId === "ntfy-message"
|
||||||
? { messageEventType: "message:agent-to-user" }
|
? { messageEventType: "message:agent-to-user" }
|
||||||
: {
|
: providerId === "ntfy-room"
|
||||||
webhookUrl: form.webhookUrl,
|
? { messageEventType: "message:room" }
|
||||||
webhookFormat: form.webhookFormat || "generic",
|
: {
|
||||||
};
|
webhookUrl: form.webhookUrl,
|
||||||
const result = await testNotification(providerId === "ntfy-message" ? "ntfy" : providerId, config, projectId);
|
webhookFormat: form.webhookFormat || "generic",
|
||||||
|
};
|
||||||
|
const result = await testNotification(
|
||||||
|
providerId === "ntfy-message" || providerId === "ntfy-room" ? "ntfy" : providerId,
|
||||||
|
config,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const providerName = providerId === "ntfy"
|
const providerName = providerId === "ntfy"
|
||||||
? "ntfy app"
|
? "ntfy app"
|
||||||
: providerId === "ntfy-message"
|
: providerId === "ntfy-message" || providerId === "ntfy-room"
|
||||||
? "ntfy app inbox"
|
? "ntfy app inbox"
|
||||||
: "webhook endpoint";
|
: "webhook endpoint";
|
||||||
const successMessage = `Test notification sent — check your ${providerName}!`;
|
const successMessage = `Test notification sent — check your ${providerName}!`;
|
||||||
@@ -4987,6 +4995,7 @@ export function SettingsModal({
|
|||||||
disabled={
|
disabled={
|
||||||
testNotificationLoading["ntfy"] ||
|
testNotificationLoading["ntfy"] ||
|
||||||
testNotificationLoading["ntfy-message"] ||
|
testNotificationLoading["ntfy-message"] ||
|
||||||
|
testNotificationLoading["ntfy-room"] ||
|
||||||
!form.ntfyEnabled ||
|
!form.ntfyEnabled ||
|
||||||
!form.ntfyTopic ||
|
!form.ntfyTopic ||
|
||||||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
||||||
@@ -5001,6 +5010,7 @@ export function SettingsModal({
|
|||||||
disabled={
|
disabled={
|
||||||
testNotificationLoading["ntfy"] ||
|
testNotificationLoading["ntfy"] ||
|
||||||
testNotificationLoading["ntfy-message"] ||
|
testNotificationLoading["ntfy-message"] ||
|
||||||
|
testNotificationLoading["ntfy-room"] ||
|
||||||
!form.ntfyEnabled ||
|
!form.ntfyEnabled ||
|
||||||
!form.ntfyTopic ||
|
!form.ntfyTopic ||
|
||||||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
||||||
@@ -5008,8 +5018,23 @@ export function SettingsModal({
|
|||||||
>
|
>
|
||||||
{testNotificationLoading["ntfy-message"] ? "Sending…" : "Test message notification"}
|
{testNotificationLoading["ntfy-message"] ? "Sending…" : "Test message notification"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => handleTestProviderNotification("ntfy-room")}
|
||||||
|
disabled={
|
||||||
|
testNotificationLoading["ntfy"] ||
|
||||||
|
testNotificationLoading["ntfy-message"] ||
|
||||||
|
testNotificationLoading["ntfy-room"] ||
|
||||||
|
!form.ntfyEnabled ||
|
||||||
|
!form.ntfyTopic ||
|
||||||
|
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{testNotificationLoading["ntfy-room"] ? "Sending…" : "Send test room notification"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"]) && (
|
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
|
||||||
<div className="notification-test-feedback" aria-live="polite">
|
<div className="notification-test-feedback" aria-live="polite">
|
||||||
{testNotificationResult["ntfy"] && (
|
{testNotificationResult["ntfy"] && (
|
||||||
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>
|
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>
|
||||||
@@ -5021,6 +5046,11 @@ export function SettingsModal({
|
|||||||
{testNotificationResult["ntfy-message"].message}
|
{testNotificationResult["ntfy-message"].message}
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
|
{testNotificationResult["ntfy-room"] && (
|
||||||
|
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-room"].status}`}>
|
||||||
|
{testNotificationResult["ntfy-room"].message}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3071,7 +3071,7 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByLabelText("Access token (optional)")).toBeInTheDocument();
|
expect(screen.getByLabelText("Access token (optional)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows fallback, dreams, and mailbox message events for both providers", async () => {
|
it("shows fallback, dreams, and mailbox/room message events for both providers", async () => {
|
||||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
@@ -3081,16 +3081,20 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
||||||
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
|
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
|
||||||
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
|
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
|
||||||
|
const roomMessageNtfy = screen.getByLabelText("Agent message in room") as HTMLInputElement;
|
||||||
expect(agentToUserNtfy.checked).toBe(true);
|
expect(agentToUserNtfy.checked).toBe(true);
|
||||||
expect(agentToAgentNtfy.checked).toBe(true);
|
expect(agentToAgentNtfy.checked).toBe(true);
|
||||||
|
expect(roomMessageNtfy.checked).toBe(true);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
||||||
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
||||||
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
|
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
|
||||||
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
|
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
|
||||||
|
const [roomMessageWebhook] = screen.getAllByLabelText("Agent message in room") as HTMLInputElement[];
|
||||||
expect(agentToUserWebhook.checked).toBe(true);
|
expect(agentToUserWebhook.checked).toBe(true);
|
||||||
expect(agentToAgentWebhook.checked).toBe(true);
|
expect(agentToAgentWebhook.checked).toBe(true);
|
||||||
|
expect(roomMessageWebhook.checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows webhook fields when webhook provider is enabled", async () => {
|
it("shows webhook fields when webhook provider is enabled", async () => {
|
||||||
@@ -3187,6 +3191,28 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getAllByText("Test notification sent — check your ntfy app inbox!")[0].closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite");
|
expect(screen.getAllByText("Test notification sent — check your ntfy app inbox!")[0].closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls testNotification with ntfy room-event config when room test button clicked", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
renderModal({ addToast });
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await openNotificationsSection();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Send test room notification/ }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||||
|
"ntfy",
|
||||||
|
{ messageEventType: "message:room" },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith(
|
||||||
|
"Test notification sent — check your ntfy app inbox!",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("calls testNotification with webhook provider ID when webhook test button clicked", async () => {
|
it("calls testNotification with webhook provider ID when webhook test button clicked", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|||||||
@@ -1872,6 +1872,39 @@ describe("POST /settings/test-notification", () => {
|
|||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
|
||||||
|
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ntfyEnabled: true,
|
||||||
|
ntfyTopic: "test-topic",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/settings/test-notification",
|
||||||
|
JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ success: true });
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||||
|
"message:room",
|
||||||
|
expect.objectContaining({
|
||||||
|
event: "message:room",
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
roomId: "test-room",
|
||||||
|
roomName: "Test Room",
|
||||||
|
senderName: "Fusion",
|
||||||
|
preview: "Fusion test room notification",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("ntfy provider uses config override for baseUrl", async () => {
|
it("ntfy provider uses config override for baseUrl", async () => {
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ntfyEnabled: true,
|
ntfyEnabled: true,
|
||||||
|
|||||||
@@ -1939,8 +1939,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
if (
|
if (
|
||||||
requestedMessageEventType !== "message:agent-to-user"
|
requestedMessageEventType !== "message:agent-to-user"
|
||||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||||
|
&& requestedMessageEventType !== "message:room"
|
||||||
) {
|
) {
|
||||||
throw badRequest("messageEventType must be message:agent-to-user or message:agent-to-agent");
|
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationService = getActiveNotificationService();
|
const notificationService = getActiveNotificationService();
|
||||||
@@ -1949,21 +1950,39 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
|
const messageId = `test-${crypto.randomUUID()}`;
|
||||||
await notificationService.dispatch(requestedMessageEventType, {
|
if (requestedMessageEventType === "message:room") {
|
||||||
taskId: undefined,
|
await notificationService.dispatch(requestedMessageEventType, {
|
||||||
taskTitle: undefined,
|
taskId: undefined,
|
||||||
event: requestedMessageEventType,
|
taskTitle: undefined,
|
||||||
metadata: {
|
event: requestedMessageEventType,
|
||||||
messageId: `test-${crypto.randomUUID()}`,
|
metadata: {
|
||||||
fromId: "system",
|
messageId,
|
||||||
fromType: "agent",
|
roomId: "test-room",
|
||||||
toId: "user",
|
roomName: "Test Room",
|
||||||
toType: "user",
|
senderAgentId: "system",
|
||||||
type: messageType,
|
senderName: "Fusion",
|
||||||
preview: "Fusion test message notification",
|
preview: "Fusion test room notification",
|
||||||
},
|
type: "room-assistant",
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
|
||||||
|
await notificationService.dispatch(requestedMessageEventType, {
|
||||||
|
taskId: undefined,
|
||||||
|
taskTitle: undefined,
|
||||||
|
event: requestedMessageEventType,
|
||||||
|
metadata: {
|
||||||
|
messageId,
|
||||||
|
fromId: "system",
|
||||||
|
fromType: "agent",
|
||||||
|
toId: "user",
|
||||||
|
toType: "user",
|
||||||
|
type: messageType,
|
||||||
|
preview: "Fusion test message notification",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -610,6 +610,12 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
|
|
||||||
// Create ChatStore for chat session management (available for SSE event forwarding)
|
// Create ChatStore for chat session management (available for SSE event forwarding)
|
||||||
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
||||||
|
options?.engine?.attachChatStore?.(chatStore);
|
||||||
|
if (typeof options?.engineManager?.getAllEngines === "function") {
|
||||||
|
for (const engine of options.engineManager.getAllEngines().values()) {
|
||||||
|
engine.attachChatStore?.(chatStore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lets the browser explicitly release server-side SSE listeners during page
|
// Lets the browser explicitly release server-side SSE listeners during page
|
||||||
// unload. EventSource.close() is not enough in Chrome refresh paths because
|
// unload. EventSource.close() is not enough in Chrome refresh paths because
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { Message, NotificationProvider, Settings, Task } from "@fusion/core";
|
import type { ChatRoomMessage, Message, NotificationProvider, Settings, Task } from "@fusion/core";
|
||||||
import { NotificationService } from "../notification/notification-service.js";
|
import { NotificationService } from "../notification/notification-service.js";
|
||||||
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
||||||
import { schedulerLog } from "../logger.js";
|
import { schedulerLog } from "../logger.js";
|
||||||
@@ -65,6 +65,22 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
|||||||
} as Message;
|
} as Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRoomMessage(overrides: Partial<ChatRoomMessage> = {}): ChatRoomMessage {
|
||||||
|
return {
|
||||||
|
id: "rmsg-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "hello from room agent",
|
||||||
|
thinkingOutput: null,
|
||||||
|
metadata: null,
|
||||||
|
attachments: [],
|
||||||
|
senderAgentId: "agent-1",
|
||||||
|
mentions: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("NotificationService", () => {
|
describe("NotificationService", () => {
|
||||||
it("dispatches in-review event to registered provider", async () => {
|
it("dispatches in-review event to registered provider", async () => {
|
||||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
@@ -263,6 +279,98 @@ describe("NotificationService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches message:room from chat:room:message:added", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const chatStore = new EventEmitter() as EventEmitter & {
|
||||||
|
getRoom: (id: string) => { id: string; name: string } | undefined;
|
||||||
|
};
|
||||||
|
chatStore.getRoom = (id: string) => (id === "room-1" ? { id, name: "Incident Room" } : undefined);
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, {
|
||||||
|
chatStore: chatStore as any,
|
||||||
|
agentNameResolver: (agentId) => (agentId === "agent-1" ? "Triage Bot" : null),
|
||||||
|
});
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
chatStore.emit("chat:room:message:added", createRoomMessage());
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(sendNotification).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
"message:room",
|
||||||
|
expect.objectContaining({
|
||||||
|
event: "message:room",
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
messageId: "rmsg-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomName: "Incident Room",
|
||||||
|
senderAgentId: "agent-1",
|
||||||
|
senderName: "Triage Bot",
|
||||||
|
preview: "hello from room agent",
|
||||||
|
type: "room-assistant",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches room notifications when chat store attaches after start", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const chatStore = new EventEmitter() as EventEmitter & {
|
||||||
|
getRoom: (id: string) => { id: string; name: string } | undefined;
|
||||||
|
};
|
||||||
|
chatStore.getRoom = (id: string) => (id === "room-1" ? { id, name: "Incident Room" } : undefined);
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, {
|
||||||
|
agentNameResolver: () => "Triage Bot",
|
||||||
|
});
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
service.attachChatStore(chatStore as any);
|
||||||
|
|
||||||
|
chatStore.emit("chat:room:message:added", createRoomMessage());
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
"message:room",
|
||||||
|
expect.objectContaining({ event: "message:room" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-agent or non-assistant room messages", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const chatStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, { chatStore: chatStore as any });
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
chatStore.emit("chat:room:message:added", createRoomMessage({ role: "user" }));
|
||||||
|
chatStore.emit("chat:room:message:added", createRoomMessage({ id: "rmsg-2", senderAgentId: null }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("dispatches even when agent name resolution fails", async () => {
|
it("dispatches even when agent name resolution fails", async () => {
|
||||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
const messageStore = new EventEmitter();
|
const messageStore = new EventEmitter();
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe("Ntfy notifier helpers", () => {
|
|||||||
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
|
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-agent");
|
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-agent");
|
||||||
|
expect(DEFAULT_NTFY_EVENTS).toContain("message:room");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks planning-awaiting-input event enablement", () => {
|
it("checks planning-awaiting-input event enablement", () => {
|
||||||
@@ -106,6 +107,18 @@ describe("Ntfy notifier helpers", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("http://localhost:4040/?project=proj-1&view=mailbox&mailbox-message=msg-1#message-msg-1");
|
).toBe("http://localhost:4040/?project=proj-1&view=mailbox&mailbox-message=msg-1#message-msg-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds room message deep links", () => {
|
||||||
|
expect(
|
||||||
|
buildNtfyClickUrl({
|
||||||
|
dashboardHost: "http://localhost:4040/",
|
||||||
|
projectId: "proj-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
messageId: "msg-1",
|
||||||
|
view: "rooms",
|
||||||
|
}),
|
||||||
|
).toBe("http://localhost:4040/?project=proj-1&view=rooms&room=room-1#message-msg-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendNtfyNotificationWithResult", () => {
|
describe("sendNtfyNotificationWithResult", () => {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
||||||
["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"],
|
["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"],
|
||||||
["message:agent-to-agent", "Triage Bot → Executor Bot", "Triage Bot messaged Executor Bot: preview text", "default"],
|
["message:agent-to-agent", "Triage Bot → Executor Bot", "Triage Bot messaged Executor Bot: preview text", "default"],
|
||||||
|
["message:room", "#Incident Room — Triage Bot", "Triage Bot in #Incident Room: preview text", "default"],
|
||||||
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
||||||
await provider.sendNotification(event as any, {
|
await provider.sendNotification(event as any, {
|
||||||
taskId: "FN-1",
|
taskId: "FN-1",
|
||||||
@@ -56,8 +57,12 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
toId: "agent-2",
|
toId: "agent-2",
|
||||||
fromName: "Triage Bot",
|
fromName: "Triage Bot",
|
||||||
toName: "Executor Bot",
|
toName: "Executor Bot",
|
||||||
|
senderAgentId: "agent-1",
|
||||||
|
senderName: "Triage Bot",
|
||||||
preview: "preview text",
|
preview: "preview text",
|
||||||
messageId: "msg-1",
|
messageId: "msg-1",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomName: "Incident Room",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
|
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true);
|
expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true);
|
||||||
|
expect(provider.isEventSupported("message:room" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +130,28 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses room deep link for room notifications", async () => {
|
||||||
|
await provider.sendNotification("message:room" as any, {
|
||||||
|
event: "message:room" as any,
|
||||||
|
metadata: {
|
||||||
|
messageId: "msg-room",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomName: "Incident Room",
|
||||||
|
senderAgentId: "agent-1",
|
||||||
|
senderName: "Triage Bot",
|
||||||
|
preview: "hello",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
roomId: "room-1",
|
||||||
|
messageId: "msg-room",
|
||||||
|
view: "rooms",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses mailbox deep link when message is not task-bound", async () => {
|
it("uses mailbox deep link when message is not task-bound", async () => {
|
||||||
await provider.sendNotification("message:agent-to-agent" as any, {
|
await provider.sendNotification("message:agent-to-agent" as any, {
|
||||||
event: "message:agent-to-agent" as any,
|
event: "message:agent-to-agent" as any,
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
["fallback-used", "Fusion recovered by switching from"],
|
["fallback-used", "Fusion recovered by switching from"],
|
||||||
["message:agent-to-user", "From: Triage Bot → You: hello"],
|
["message:agent-to-user", "From: Triage Bot → You: hello"],
|
||||||
["message:agent-to-agent", "From: Triage Bot → To: Executor Bot: hello"],
|
["message:agent-to-agent", "From: Triage Bot → To: Executor Bot: hello"],
|
||||||
|
["message:room", "In #Incident Room: Triage Bot: hello"],
|
||||||
["unknown-event", 'Event "unknown-event" for task My Task'],
|
["unknown-event", 'Event "unknown-event" for task My Task'],
|
||||||
])("message formatting for %s", async (event, expectedPart) => {
|
])("message formatting for %s", async (event, expectedPart) => {
|
||||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||||
@@ -176,7 +177,17 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
taskId: "FN-1",
|
taskId: "FN-1",
|
||||||
taskTitle: "My Task",
|
taskTitle: "My Task",
|
||||||
event,
|
event,
|
||||||
metadata: { fromId: "agent-1", toId: "agent-2", fromName: "Triage Bot", toName: "Executor Bot", preview: "hello" },
|
metadata: {
|
||||||
|
fromId: "agent-1",
|
||||||
|
toId: "agent-2",
|
||||||
|
fromName: "Triage Bot",
|
||||||
|
toName: "Executor Bot",
|
||||||
|
senderAgentId: "agent-1",
|
||||||
|
senderName: "Triage Bot",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomName: "Incident Room",
|
||||||
|
preview: "hello",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
@@ -184,6 +195,40 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
expect(body.text).toContain(expectedPart);
|
expect(body.text).toContain(expectedPart);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes room metadata and room deep link for room notifications", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||||
|
await provider.initialize({
|
||||||
|
webhookUrl: "https://example.com/hook",
|
||||||
|
webhookFormat: "generic",
|
||||||
|
dashboardHost: "http://dash",
|
||||||
|
projectId: "p1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.sendNotification("message:room", {
|
||||||
|
event: "message:room",
|
||||||
|
metadata: {
|
||||||
|
messageId: "msg-room",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomName: "Incident Room",
|
||||||
|
senderAgentId: "agent-1",
|
||||||
|
senderName: "Triage Bot",
|
||||||
|
preview: "hello",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const payload = JSON.parse(String(requestInit.body));
|
||||||
|
expect(payload.metadata).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
roomId: "room-1",
|
||||||
|
roomName: "Incident Room",
|
||||||
|
senderAgentId: "agent-1",
|
||||||
|
senderName: "Triage Bot",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.clickUrl).toBe("http://dash/?project=p1&view=rooms&room=room-1#message-msg-room");
|
||||||
|
});
|
||||||
|
|
||||||
it("title fallback uses taskId and truncated description snippet", async () => {
|
it("title fallback uses taskId and truncated description snippet", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ChatRoomMessage,
|
||||||
Column,
|
Column,
|
||||||
MergeResult,
|
MergeResult,
|
||||||
Message,
|
Message,
|
||||||
@@ -21,6 +22,8 @@ export interface NotificationServiceOptions {
|
|||||||
ntfyBaseUrl?: string;
|
ntfyBaseUrl?: string;
|
||||||
/** Optional message store for mailbox message notifications */
|
/** Optional message store for mailbox message notifications */
|
||||||
messageStore?: NotificationMessageStore;
|
messageStore?: NotificationMessageStore;
|
||||||
|
/** Optional chat store for room message notifications */
|
||||||
|
chatStore?: NotificationChatStore;
|
||||||
/** Resolve human-readable name for an agent ID used in message notifications */
|
/** Resolve human-readable name for an agent ID used in message notifications */
|
||||||
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
|
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
|
||||||
}
|
}
|
||||||
@@ -36,10 +39,17 @@ interface NotificationMessageStore {
|
|||||||
off?(event: "message:sent", listener: (message: Message) => void): void;
|
off?(event: "message:sent", listener: (message: Message) => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationChatStore {
|
||||||
|
on(event: "chat:room:message:added", listener: (message: ChatRoomMessage) => void): void;
|
||||||
|
off?(event: "chat:room:message:added", listener: (message: ChatRoomMessage) => void): void;
|
||||||
|
getRoom?(id: string): { id: string; name: string } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
private readonly dispatcher = new NotificationDispatcher();
|
private readonly dispatcher = new NotificationDispatcher();
|
||||||
private readonly notifiedEvents = new Set<string>();
|
private readonly notifiedEvents = new Set<string>();
|
||||||
private started = false;
|
private started = false;
|
||||||
|
private chatStore: NotificationChatStore | undefined;
|
||||||
private notificationsEnabled = false;
|
private notificationsEnabled = false;
|
||||||
private ntfyProvider?: NtfyNotificationProvider;
|
private ntfyProvider?: NtfyNotificationProvider;
|
||||||
private webhookProvider?: WebhookNotificationProvider;
|
private webhookProvider?: WebhookNotificationProvider;
|
||||||
@@ -48,7 +58,19 @@ export class NotificationService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly store: NotificationServiceStore,
|
private readonly store: NotificationServiceStore,
|
||||||
private readonly options: NotificationServiceOptions = {},
|
private readonly options: NotificationServiceOptions = {},
|
||||||
) {}
|
) {
|
||||||
|
this.chatStore = options.chatStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachChatStore(chatStore: NotificationChatStore): void {
|
||||||
|
if (this.chatStore && this.chatStore !== chatStore) {
|
||||||
|
this.detachChatStoreListener(this.chatStore);
|
||||||
|
}
|
||||||
|
this.chatStore = chatStore;
|
||||||
|
if (this.started) {
|
||||||
|
this.chatStore.on("chat:room:message:added", this.handleRoomMessageAdded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
registerProvider(provider: NotificationProvider): void {
|
registerProvider(provider: NotificationProvider): void {
|
||||||
this.dispatcher.registerProvider(provider);
|
this.dispatcher.registerProvider(provider);
|
||||||
@@ -71,8 +93,8 @@ export class NotificationService {
|
|||||||
this.store.on("task:merged", this.handleTaskMerged);
|
this.store.on("task:merged", this.handleTaskMerged);
|
||||||
this.store.on("settings:updated", this.handleSettingsUpdated);
|
this.store.on("settings:updated", this.handleSettingsUpdated);
|
||||||
this.options.messageStore?.on("message:sent", this.handleMessageSent);
|
this.options.messageStore?.on("message:sent", this.handleMessageSent);
|
||||||
|
|
||||||
this.started = true;
|
this.started = true;
|
||||||
|
this.chatStore?.on("chat:room:message:added", this.handleRoomMessageAdded);
|
||||||
schedulerLog.log("NotificationService started");
|
schedulerLog.log("NotificationService started");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +111,7 @@ export class NotificationService {
|
|||||||
if (typeof this.options.messageStore?.off === "function") {
|
if (typeof this.options.messageStore?.off === "function") {
|
||||||
this.options.messageStore.off("message:sent", this.handleMessageSent);
|
this.options.messageStore.off("message:sent", this.handleMessageSent);
|
||||||
}
|
}
|
||||||
|
this.detachChatStoreListener(this.chatStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dispatcher.shutdownAll();
|
await this.dispatcher.shutdownAll();
|
||||||
@@ -246,6 +269,10 @@ export class NotificationService {
|
|||||||
void this.handleMessageSentAsync(message);
|
void this.handleMessageSentAsync(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleRoomMessageAdded = (message: ChatRoomMessage): void => {
|
||||||
|
void this.handleRoomMessageAddedAsync(message);
|
||||||
|
};
|
||||||
|
|
||||||
private async handleMessageSentAsync(message: Message): Promise<void> {
|
private async handleMessageSentAsync(message: Message): Promise<void> {
|
||||||
schedulerLog.log(
|
schedulerLog.log(
|
||||||
`NotificationService.handleMessageSent messageId=${message.id} type=${message.type} notificationsEnabled=${String(this.notificationsEnabled)} hasNtfyProvider=${String(Boolean(this.ntfyProvider))}`,
|
`NotificationService.handleMessageSent messageId=${message.id} type=${message.type} notificationsEnabled=${String(this.notificationsEnabled)} hasNtfyProvider=${String(Boolean(this.ntfyProvider))}`,
|
||||||
@@ -267,9 +294,7 @@ export class NotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = message.content.length > 100
|
const preview = this.createPreview(message.content);
|
||||||
? `${message.content.slice(0, 100)}…`
|
|
||||||
: message.content;
|
|
||||||
|
|
||||||
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
|
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
|
||||||
|
|
||||||
@@ -299,6 +324,44 @@ export class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleRoomMessageAddedAsync(message: ChatRoomMessage): Promise<void> {
|
||||||
|
schedulerLog.log(
|
||||||
|
`NotificationService.handleRoomMessageAdded messageId=${message.id} roomId=${message.roomId} role=${message.role} notificationsEnabled=${String(this.notificationsEnabled)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (message.role !== "assistant" || message.senderAgentId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.notificationsEnabled) {
|
||||||
|
await this.refreshNotificationState("chat:room:message:added");
|
||||||
|
if (!this.notificationsEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderName = await this.resolveAgentName("agent", message.senderAgentId, "from");
|
||||||
|
const roomName = this.chatStore?.getRoom?.(message.roomId)?.name;
|
||||||
|
const preview = this.createPreview(message.content);
|
||||||
|
|
||||||
|
this.maybeNotify(message.id, "message:room", {
|
||||||
|
event: "message:room",
|
||||||
|
metadata: {
|
||||||
|
messageId: message.id,
|
||||||
|
roomId: message.roomId,
|
||||||
|
...(roomName ? { roomName } : {}),
|
||||||
|
senderAgentId: message.senderAgentId,
|
||||||
|
...(senderName ? { senderName } : {}),
|
||||||
|
preview,
|
||||||
|
type: "room-assistant",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
schedulerLog.log(
|
||||||
|
`NotificationService.handleRoomMessageAdded scheduled eventType=message:room messageId=${message.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveAgentName(
|
private async resolveAgentName(
|
||||||
participantType: Message["fromType"],
|
participantType: Message["fromType"],
|
||||||
participantId: string,
|
participantId: string,
|
||||||
@@ -326,6 +389,16 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createPreview(content: string): string {
|
||||||
|
return content.length > 100 ? `${content.slice(0, 100)}…` : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachChatStoreListener(chatStore: NotificationChatStore | undefined): void {
|
||||||
|
if (typeof chatStore?.off === "function") {
|
||||||
|
chatStore.off("chat:room:message:added", this.handleRoomMessageAdded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
||||||
this.notificationsEnabled = Boolean(
|
this.notificationsEnabled = Boolean(
|
||||||
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ type SupportedNtfyEvent =
|
|||||||
| "planning-awaiting-input"
|
| "planning-awaiting-input"
|
||||||
| "fallback-used"
|
| "fallback-used"
|
||||||
| "message:agent-to-user"
|
| "message:agent-to-user"
|
||||||
| "message:agent-to-agent";
|
| "message:agent-to-agent"
|
||||||
|
| "message:room";
|
||||||
|
|
||||||
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||||
"in-review",
|
"in-review",
|
||||||
@@ -51,6 +52,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
|||||||
"fallback-used",
|
"fallback-used",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
"message:room",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function resolveParticipantLabel(
|
export function resolveParticipantLabel(
|
||||||
@@ -67,6 +69,24 @@ export function resolveParticipantLabel(
|
|||||||
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRoomSenderLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||||
|
const senderName = typeof metadata?.senderName === "string" ? metadata.senderName.trim() : "";
|
||||||
|
if (senderName.length > 0) {
|
||||||
|
return senderName;
|
||||||
|
}
|
||||||
|
const senderAgentId = typeof metadata?.senderAgentId === "string" ? metadata.senderAgentId.trim() : "";
|
||||||
|
return senderAgentId.length > 0 ? senderAgentId : "agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoomLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||||
|
const roomName = typeof metadata?.roomName === "string" ? metadata.roomName.trim() : "";
|
||||||
|
if (roomName.length > 0) {
|
||||||
|
return roomName;
|
||||||
|
}
|
||||||
|
const roomId = typeof metadata?.roomId === "string" ? metadata.roomId.trim() : "";
|
||||||
|
return roomId.length > 0 ? roomId : "room";
|
||||||
|
}
|
||||||
|
|
||||||
export class NtfyNotificationProvider implements NotificationProvider {
|
export class NtfyNotificationProvider implements NotificationProvider {
|
||||||
private config?: NtfyProviderConfig;
|
private config?: NtfyProviderConfig;
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
@@ -137,14 +157,25 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
|||||||
const replyToMessageId = typeof payload.metadata?.replyToMessageId === "string"
|
const replyToMessageId = typeof payload.metadata?.replyToMessageId === "string"
|
||||||
? payload.metadata.replyToMessageId
|
? payload.metadata.replyToMessageId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const roomSenderLabel = resolveRoomSenderLabel(payload.metadata);
|
||||||
|
const roomLabel = resolveRoomLabel(payload.metadata);
|
||||||
|
const roomId = typeof payload.metadata?.roomId === "string" ? payload.metadata.roomId : undefined;
|
||||||
|
|
||||||
const clickUrl = buildNtfyClickUrl({
|
const clickUrl = event === "message:room"
|
||||||
dashboardHost: this.config.dashboardHost,
|
? buildNtfyClickUrl({
|
||||||
projectId: this.config.projectId,
|
dashboardHost: this.config.dashboardHost,
|
||||||
taskId: payload.taskId,
|
projectId: this.config.projectId,
|
||||||
messageId,
|
roomId,
|
||||||
view: "mailbox",
|
messageId,
|
||||||
});
|
view: "rooms",
|
||||||
|
})
|
||||||
|
: buildNtfyClickUrl({
|
||||||
|
dashboardHost: this.config.dashboardHost,
|
||||||
|
projectId: this.config.projectId,
|
||||||
|
taskId: payload.taskId,
|
||||||
|
messageId,
|
||||||
|
view: "mailbox",
|
||||||
|
});
|
||||||
|
|
||||||
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
|
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
|
||||||
"in-review": {
|
"in-review": {
|
||||||
@@ -192,6 +223,11 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
|||||||
message: `${senderLabel} messaged ${recipientLabel}: ${preview}`,
|
message: `${senderLabel} messaged ${recipientLabel}: ${preview}`,
|
||||||
priority: "default",
|
priority: "default",
|
||||||
},
|
},
|
||||||
|
"message:room": {
|
||||||
|
title: `#${roomLabel} — ${roomSenderLabel}`,
|
||||||
|
message: `${roomSenderLabel} in #${roomLabel}: ${preview}`,
|
||||||
|
priority: "default",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = contentByEvent[event as SupportedNtfyEvent];
|
const content = contentByEvent[event as SupportedNtfyEvent];
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ function resolveParticipantLabel(
|
|||||||
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRoomSenderLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||||
|
const senderName = typeof metadata?.senderName === "string" ? metadata.senderName.trim() : "";
|
||||||
|
if (senderName.length > 0) {
|
||||||
|
return senderName;
|
||||||
|
}
|
||||||
|
const senderAgentId = typeof metadata?.senderAgentId === "string" ? metadata.senderAgentId.trim() : "";
|
||||||
|
return senderAgentId.length > 0 ? senderAgentId : "agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoomLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||||
|
const roomName = typeof metadata?.roomName === "string" ? metadata.roomName.trim() : "";
|
||||||
|
if (roomName.length > 0) {
|
||||||
|
return roomName;
|
||||||
|
}
|
||||||
|
const roomId = typeof metadata?.roomId === "string" ? metadata.roomId.trim() : "";
|
||||||
|
return roomId.length > 0 ? roomId : "room";
|
||||||
|
}
|
||||||
|
|
||||||
export class WebhookNotificationProvider implements NotificationProvider {
|
export class WebhookNotificationProvider implements NotificationProvider {
|
||||||
private config: WebhookProviderConfig | null = null;
|
private config: WebhookProviderConfig | null = null;
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
@@ -167,6 +185,12 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
|
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
|
||||||
return `From: ${from} → To: ${to}: ${preview}`;
|
return `From: ${from} → To: ${to}: ${preview}`;
|
||||||
}
|
}
|
||||||
|
case "message:room": {
|
||||||
|
const roomName = resolveRoomLabel(payload.metadata);
|
||||||
|
const senderLabel = resolveRoomSenderLabel(payload.metadata);
|
||||||
|
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
|
||||||
|
return `In #${roomName}: ${senderLabel}: ${preview}`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return `Event "${event}" for task ${identifier}`;
|
return `Event "${event}" for task ${identifier}`;
|
||||||
}
|
}
|
||||||
@@ -196,9 +220,12 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||||
|
const roomId = typeof payload.metadata?.roomId === "string" ? payload.metadata.roomId : undefined;
|
||||||
|
|
||||||
const fromLabel = resolveParticipantLabel(payload.metadata, "from");
|
const fromLabel = resolveParticipantLabel(payload.metadata, "from");
|
||||||
const toLabel = resolveParticipantLabel(payload.metadata, "to");
|
const toLabel = resolveParticipantLabel(payload.metadata, "to");
|
||||||
|
const roomLabel = resolveRoomLabel(payload.metadata);
|
||||||
|
const roomSenderLabel = resolveRoomSenderLabel(payload.metadata);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event: payload.event,
|
event: payload.event,
|
||||||
@@ -215,14 +242,28 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
toName: typeof payload.metadata?.toName === "string" ? payload.metadata.toName : toLabel,
|
toName: typeof payload.metadata?.toName === "string" ? payload.metadata.toName : toLabel,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(payload.event === "message:room"
|
||||||
|
? {
|
||||||
|
roomName: typeof payload.metadata?.roomName === "string" ? payload.metadata.roomName : roomLabel,
|
||||||
|
senderName: typeof payload.metadata?.senderName === "string" ? payload.metadata.senderName : roomSenderLabel,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
clickUrl: buildNtfyClickUrl({
|
clickUrl: payload.event === "message:room"
|
||||||
dashboardHost: this.config.dashboardHost,
|
? buildNtfyClickUrl({
|
||||||
projectId: this.config.projectId,
|
dashboardHost: this.config.dashboardHost,
|
||||||
taskId: payload.taskId,
|
projectId: this.config.projectId,
|
||||||
messageId,
|
roomId,
|
||||||
view: "mailbox",
|
messageId,
|
||||||
}),
|
view: "rooms",
|
||||||
|
})
|
||||||
|
: buildNtfyClickUrl({
|
||||||
|
dashboardHost: this.config.dashboardHost,
|
||||||
|
projectId: this.config.projectId,
|
||||||
|
taskId: payload.taskId,
|
||||||
|
messageId,
|
||||||
|
view: "mailbox",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
|||||||
"fallback-used",
|
"fallback-used",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
"message:room",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export interface NtfyNotificationConfigInput {
|
export interface NtfyNotificationConfigInput {
|
||||||
@@ -150,9 +151,10 @@ export function buildNtfyClickUrl(options: {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
roomId?: string;
|
||||||
view?: string;
|
view?: string;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const { dashboardHost, projectId, taskId, messageId, view } = options;
|
const { dashboardHost, projectId, taskId, messageId, roomId, view } = options;
|
||||||
if (!dashboardHost) {
|
if (!dashboardHost) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,9 @@ export function buildNtfyClickUrl(options: {
|
|||||||
}
|
}
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
queryParts.push(`task=${encodeURIComponent(taskId)}`);
|
queryParts.push(`task=${encodeURIComponent(taskId)}`);
|
||||||
|
} else if (roomId) {
|
||||||
|
queryParts.push(`view=${encodeURIComponent(view ?? "rooms")}`);
|
||||||
|
queryParts.push(`room=${encodeURIComponent(roomId)}`);
|
||||||
} else if (messageId) {
|
} else if (messageId) {
|
||||||
queryParts.push(`view=${encodeURIComponent(view ?? "mailbox")}`);
|
queryParts.push(`view=${encodeURIComponent(view ?? "mailbox")}`);
|
||||||
queryParts.push(`mailbox-message=${encodeURIComponent(messageId)}`);
|
queryParts.push(`mailbox-message=${encodeURIComponent(messageId)}`);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { PrMonitor } from "./pr-monitor.js";
|
|||||||
import { PrCommentHandler } from "./pr-comment-handler.js";
|
import { PrCommentHandler } from "./pr-comment-handler.js";
|
||||||
import { NtfyNotifier } from "./notifier.js";
|
import { NtfyNotifier } from "./notifier.js";
|
||||||
import { NotificationService } from "./notification/index.js";
|
import { NotificationService } from "./notification/index.js";
|
||||||
|
import type { NotificationChatStore } from "./notification/notification-service.js";
|
||||||
import { GridlockDetector } from "./gridlock-detector.js";
|
import { GridlockDetector } from "./gridlock-detector.js";
|
||||||
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
|
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
|
||||||
import type { RoutineRunner } from "./routine-runner.js";
|
import type { RoutineRunner } from "./routine-runner.js";
|
||||||
@@ -601,6 +602,10 @@ export class ProjectEngine {
|
|||||||
return this.runtime.getMessageStore();
|
return this.runtime.getMessageStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachChatStore(chatStore: NotificationChatStore): void {
|
||||||
|
this.notificationService?.attachChatStore(chatStore);
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the HeartbeatMonitor (if initialized). */
|
/** Get the HeartbeatMonitor (if initialized). */
|
||||||
getHeartbeatMonitor() {
|
getHeartbeatMonitor() {
|
||||||
return this.runtime.getHeartbeatMonitor();
|
return this.runtime.getHeartbeatMonitor();
|
||||||
|
|||||||
Reference in New Issue
Block a user