Skip to content
GitHubXDiscord

Chat mentions → webhook

A common product pattern: when the user is @-mentioned in chat, optionally relay the mention to an outbound integration (Slack, Discord, a webhook URL), then react to whether the relay succeeded — and let the user retry or disable the integration without writing it again. Triggery handles the chain of async work cleanly: one event in, one trigger drives the whole pipeline, side effects stay inside the components that own them.

Open in StackBlitz Open example on GitHub
  1. The chat fires chat:mentioned with the message and the integration’s current state.
  2. If the integration is on, the trigger calls a postToWebhook action (an async network call).
  3. The webhook reactor fires a follow-up event (chat:webhook-response) — success or error.
  4. A second trigger handles the response: success → toast, error → toast + suggest disabling integration.
  • Directorysrc/
    • Directorytriggers/
      • mention.trigger.ts the relay rule
      • webhook-response.trigger.ts the reaction to the result
    • Directoryfeatures/
      • Directorychat/
        • ChatStream.tsx producer (chat:mentioned)
      • Directoryintegration/
        • IntegrationProvider.tsx provider (integration)
        • WebhookReactor.tsx reactor (postToWebhook → fetch)
      • Directorynotifications/
        • MentionNotifier.tsx reactor (success / error toasts)
src/triggers/mention.trigger.ts
import { createTrigger } from '@triggery/core';

export type Integration = {
  enabled:    boolean;
  webhookUrl: string;
  kind:       'slack' | 'discord' | 'custom';
};

type Mention = {
  channelId:   string;
  messageId:   string;
  fromUserId:  string;
  fromName:    string;
  text:        string;
};

export const mentionTrigger = createTrigger<{
  events: {
    'chat:mentioned': Mention;
  };
  conditions: {
    integration: Integration;
  };
  actions: {
    showMentionToast: { from: string; text: string };
    postToWebhook:    { url: string; kind: Integration['kind']; payload: Mention };
  };
}>({
  id: 'chat-mention',
  events: ['chat:mentioned'],
  required: ['integration'],
  handler({ event, conditions, actions, check }) {
    const mention = event.payload;

    // Always notify the user themselves — the integration is *extra*.
    actions.showMentionToast?.({ from: mention.fromName, text: mention.text });

    // Relay only if the integration is enabled.
    if (check.is('integration', i => i.enabled && i.webhookUrl.length > 0)) {
      const i = conditions.integration!;
      actions.postToWebhook?.({
        url:     i.webhookUrl,
        kind:    i.kind,
        payload: mention,
      });
    }
  },
});

2. The webhook reactor (action does async, fires follow-up event)

Section titled “2. The webhook reactor (action does async, fires follow-up event)”

The reactor is where the actual network call happens. It’s an async function — actions may return promises. When the call finishes, the reactor fires chat:webhook-response, which the second trigger picks up.

src/features/integration/WebhookReactor.tsx
import { useAction, useEvent } from '@triggery/react';
import { mentionTrigger } from '../../triggers/mention.trigger';
import { webhookResponseTrigger } from '../../triggers/webhook-response.trigger';

export function WebhookReactor() {
  const fireResponse = useEvent(webhookResponseTrigger, 'chat:webhook-response');

  useAction(mentionTrigger, 'postToWebhook', async ({ url, kind, payload }) => {
    try {
      const body = formatForKind(kind, payload);
      const res  = await fetch(url, {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(body),
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      fireResponse({ ok: true,  url, kind });
    } catch (err) {
      fireResponse({ ok: false, url, kind, error: (err as Error).message });
    }
  });

  return null;
}

function formatForKind(kind: 'slack' | 'discord' | 'custom', m: { fromName: string; text: string }) {
  if (kind === 'slack')   return { text: `*${m.fromName}* mentioned you: ${m.text}` };
  if (kind === 'discord') return { content: `**${m.fromName}** mentioned you: ${m.text}` };
  return m; // raw payload for custom webhooks
}
src/triggers/webhook-response.trigger.ts
import { createTrigger } from '@triggery/core';

export const webhookResponseTrigger = createTrigger<{
  events: {
    'chat:webhook-response':
      | { ok: true;  url: string; kind: 'slack' | 'discord' | 'custom' }
      | { ok: false; url: string; kind: 'slack' | 'discord' | 'custom'; error: string };
  };
  actions: {
    showRelaySuccessToast: { kind: 'slack' | 'discord' | 'custom' };
    showRelayErrorToast:   { kind: 'slack' | 'discord' | 'custom'; error: string };
  };
}>({
  id: 'chat-webhook-response',
  events: ['chat:webhook-response'],
  handler({ event, actions }) {
    const r = event.payload;
    if (r.ok) actions.showRelaySuccessToast?.({ kind: r.kind });
    else      actions.showRelayErrorToast?.({ kind: r.kind, error: r.error });
  },
});

The two triggers stay separate on purpose — the mention rule is about “should we relay?”, the response rule is about “what to do with the result?”. Either can grow independently.

src/features/chat/ChatStream.tsx
import { useEvent } from '@triggery/react';
import { mentionTrigger } from '../../triggers/mention.trigger';

export function ChatStream({
  currentUserId,
  messages,
}: {
  currentUserId: string;
  messages: readonly { id: string; channelId: string; authorId: string; authorName: string; text: string; mentions: readonly string[] }[];
}) {
  const fireMentioned = useEvent(mentionTrigger, 'chat:mentioned');

  // Fire on every newly visible mention. In a real app you'd wire this to your
  // streaming-message subscription; for the recipe we'll show the button form.
  return (
    <ul>
      {messages.map(m =>
        m.mentions.includes(currentUserId) ? (
          <li key={m.id}>
            <strong>{m.authorName}</strong>: {m.text}
            <button
              type="button"
              onClick={() =>
                fireMentioned({
                  channelId:  m.channelId,
                  messageId:  m.id,
                  fromUserId: m.authorId,
                  fromName:   m.authorName,
                  text:       m.text,
                })
              }
            >
              simulate mention arrival
            </button>
          </li>
        ) : null,
      )}
    </ul>
  );
}
src/features/integration/IntegrationProvider.tsx
import { useCondition } from '@triggery/react';
import { mentionTrigger, type Integration } from '../../triggers/mention.trigger';

export function IntegrationProvider({
  integration,
  children,
}: {
  integration: Integration;
  children: React.ReactNode;
}) {
  useCondition(mentionTrigger, 'integration', () => integration, [integration]);
  return <>{children}</>;
}
src/features/notifications/MentionNotifier.tsx
import { useAction } from '@triggery/react';
import { toast } from 'sonner';
import { mentionTrigger } from '../../triggers/mention.trigger';
import { webhookResponseTrigger } from '../../triggers/webhook-response.trigger';

export function MentionNotifier() {
  useAction(mentionTrigger, 'showMentionToast', ({ from, text }) => {
    toast(`${from} mentioned you`, { description: text });
  });

  useAction(webhookResponseTrigger, 'showRelaySuccessToast', ({ kind }) => {
    toast.success(`Relayed to ${kind}.`);
  });

  useAction(webhookResponseTrigger, 'showRelayErrorToast', ({ kind, error }) => {
    toast.error(`Couldn't relay to ${kind}.`, { description: error });
  });

  return null;
}
src/App.tsx
import { useState } from 'react';
import { ChatStream } from './features/chat/ChatStream';
import { IntegrationProvider } from './features/integration/IntegrationProvider';
import { MentionNotifier } from './features/notifications/MentionNotifier';
import { WebhookReactor } from './features/integration/WebhookReactor';

const sampleMessages = [
  { id: 'm1', channelId: 'c1', authorId: 'u-alice', authorName: 'Alice',
    text: 'hey @bob — can you take a look at the PR?', mentions: ['u-bob'] },
];

export function App() {
  const [integration] = useState({
    enabled: true,
    webhookUrl: 'https://hooks.slack.com/services/T0/B0/X',
    kind: 'slack' as const,
  });

  return (
    <IntegrationProvider integration={integration}>
      <ChatStream currentUserId="u-bob" messages={sampleMessages} />
      <WebhookReactor />
      <MentionNotifier />
    </IntegrationProvider>
  );
}
src/triggers/mention.trigger.test.ts
import { createTestRuntime, mockAction, mockCondition } from '@triggery/testing';
import { describe, expect, it, vi } from 'vitest';
import { mentionTrigger } from './mention.trigger';

describe('chat-mention', () => {
  it('relays to the webhook when integration is enabled', async () => {
    const rt = createTestRuntime({ triggers: [mentionTrigger] });
    const postToWebhook = vi.fn();
    mockCondition(rt, mentionTrigger, 'integration', {
      enabled: true,
      webhookUrl: 'https://example.com/hook',
      kind: 'custom',
    });
    mockAction(rt, mentionTrigger, 'postToWebhook', postToWebhook);
    mockAction(rt, mentionTrigger, 'showMentionToast', () => {});

    await rt.fire('chat:mentioned', {
      channelId: 'c1', messageId: 'm1', fromUserId: 'u-a',
      fromName: 'Alice', text: 'hi',
    });

    expect(postToWebhook).toHaveBeenCalledWith({
      url: 'https://example.com/hook',
      kind: 'custom',
      payload: expect.any(Object),
    });
  });

  it('skips the webhook when integration is disabled', async () => {
    const rt = createTestRuntime({ triggers: [mentionTrigger] });
    const postToWebhook = vi.fn();
    mockCondition(rt, mentionTrigger, 'integration', {
      enabled: false, webhookUrl: '', kind: 'custom',
    });
    mockAction(rt, mentionTrigger, 'postToWebhook', postToWebhook);
    mockAction(rt, mentionTrigger, 'showMentionToast', () => {});

    await rt.fire('chat:mentioned', {
      channelId: 'c1', messageId: 'm1', fromUserId: 'u-a',
      fromName: 'Alice', text: 'hi',
    });

    expect(postToWebhook).not.toHaveBeenCalled();
  });
});