@sedibyte/dispatch
v0.5.0
Published
Provider-agnostic dispatcher for taking actions against SaaS platforms (send Teams messages, emails, subscribe to Salesforce record changes, and more to come).
Maintainers
Readme
@sedibyte/dispatch
A provider-agnostic dispatcher for taking actions against SaaS platforms — send a Teams message, an email, upsert a record — behind one small, consistent surface:
await dispatch.teams.send({ chatId: "19:[email protected]", text: "Deploy finished ✅" });The core owns one cross-cutting concern — composing providers into a single, strongly-typed object — so each SaaS integration only has to know how to perform its own actions. Adding a new provider doesn't touch the core.
Authentication is not reimplemented here: providers acquire tokens through
@sedibyte/auth, which owns the
provider registry and token caching.
Written in TypeScript; ships compiled ESM plus type declarations.
Requirements
- Node.js >= 18 (uses the built-in
fetch) - ESM (
"type": "module") @sedibyte/authas a peer dependency (you install it alongside this package)
Install
npm install @sedibyte/dispatch @sedibyte/authUsage
Compose the providers you need, then call their actions. Everything is configuration — no per-customer code.
import { createDispatch } from "@sedibyte/dispatch";
import { teams } from "@sedibyte/dispatch/teams";
const dispatch = createDispatch(
teams({
credentials: {
tenantId: process.env.MS_TENANT_ID!,
clientId: process.env.MS_CLIENT_ID!,
clientSecret: process.env.MS_CLIENT_SECRET!,
},
}),
);
// Post into a chat…
await dispatch.teams.send({ chatId: "19:[email protected]", text: "Hello 👋" });
// …or a channel…
await dispatch.teams.send({
teamId: "<team-id>",
channelId: "19:[email protected]",
html: "<b>Build 42</b> shipped",
});
// …or notify a user via their activity feed.
await dispatch.teams.send({
user: "[email protected]",
text: "Build 42 shipped",
activityType: "deploymentComplete", // must exist in your Teams app manifest
});Discord works the same way — send to a channel or a user, and discover where you can post:
import { discord } from "@sedibyte/dispatch/discord";
const dispatch = createDispatch(
discord({ botToken: process.env.DISCORD_BOT_TOKEN! }),
);
// Post into a channel…
await dispatch.discord.send({ channelId: "123", content: "Deploy finished ✅" });
// …or DM a user…
await dispatch.discord.send({ user: "456", content: "Build 42 shipped" });
// …and list the servers/channels the bot can reach.
const guilds = await dispatch.discord.listGuilds();
const channels = await dispatch.discord.listChannels({ guildId: guilds[0]!.id });Exchange sends email through Microsoft Graph, sharing the same app-only auth as Teams:
import { exchange } from "@sedibyte/dispatch/exchange";
const dispatch = createDispatch(
exchange({
credentials: {
tenantId: process.env.MS_TENANT_ID!,
clientId: process.env.MS_CLIENT_ID!,
clientSecret: process.env.MS_CLIENT_SECRET!,
},
from: "[email protected]", // default sender mailbox
}),
);
// Send an email…
await dispatch.exchange.email({
to: "[email protected]",
subject: "Deploy finished",
html: "<b>Build 42</b> shipped ✅",
});
// …with cc/bcc, a per-message sender, and an attachment.
await dispatch.exchange.email({
from: "[email protected]",
to: ["[email protected]", { address: "[email protected]", name: "Team Lead" }],
cc: "[email protected]",
subject: "Nightly report",
text: "See attached.",
attachments: [
{ name: "report.pdf", contentType: "application/pdf", content: pdfBase64 },
],
});As more providers land, the same object grows the pattern you already know:
await dispatch.teams.send({ ... });
await dispatch.discord.send({ ... });
await dispatch.exchange.email({ to: "...", subject: "...", html: "..." });
await dispatch.slack.send({ channel: "#alerts", text: "..." });
await dispatch.salesforce.upsert("Memo__c", { ... });
await dispatch.github.listCommits({ owner: "sedibyte", repo: "dispatch" });
await dispatch.jira.search({ jql: "project = PROJ ORDER BY created DESC" });Every argument is passed in explicitly — nothing is read from the environment. The caller owns loading credentials (from env, a vault, wherever).
Providers
Teams — @sedibyte/dispatch/teams
teams(options) returns a provider for createDispatch. Choose exactly one way
to authenticate:
| Option | When to use |
| --- | --- |
| credentials: { tenantId, clientId, clientSecret } | The common case. Registers a Microsoft app-only provider with @sedibyte/auth and uses it. |
| authProviderName: string | Reuse a Microsoft provider you already registered with @sedibyte/auth (e.g. a named tenant). |
| graphClient: GraphClient | Inject an existing Graph client (advanced / testing). |
Extra options: namespace (key on the dispatch object, default "teams"),
defaultActivityType (fallback for user notifications), and graphBase
(override the Graph endpoint, e.g. a sovereign cloud).
dispatch.teams.send(options) picks its target from the options:
- Chat message —
{ chatId, text | html, subject?, importance? } - Channel message —
{ teamId, channelId, text | html, subject?, importance? } - User notification —
{ user, text, activityType?, topic?, webUrl?, templateParameters? }
It returns { kind: "chat" | "channel" | "activity", id? }.
Required Microsoft Graph permissions
Grant your app registration (admin consent) the permissions for the actions you
use. These are application permissions, since Dispatch authenticates
app-only through @sedibyte/auth:
- Chat messages:
ChatMessage.Send(+Chat.ReadWrite.Allif creating chats) - Channel messages:
ChannelMessage.Send - User activity notifications:
TeamsActivity.Send
User notifications also require the activityType to be declared in your Teams
app manifest. See Microsoft's docs on
sending activity feed notifications.
Discord — @sedibyte/dispatch/discord
discord(options) returns a provider for createDispatch. Authentication is
delegated to @sedibyte/auth, which supports both Discord's static bot tokens
(Authorization: Bot <token>) and its OAuth2 application grants. Choose exactly
one way to authenticate:
| Option | When to use |
| --- | --- |
| botToken: string | The common case. Registers a bot-token provider with @sedibyte/auth and uses it. |
| credentials: DiscordCredentials | Authenticate as a Discord application via OAuth2 — the client-credentials grant ({ clientId, clientSecret }), or the refresh-token grant ({ clientId, clientSecret, grantType: "refresh_token", refreshToken }). |
| authProviderName: string | Reuse a Discord provider you already registered with @sedibyte/auth. |
| client: DiscordClient | Inject an existing @sedibyte/auth Discord client (advanced / testing). |
botToken and credentials may also take an authProviderName to register
under a specific key. Extra options: namespace (key on the dispatch object,
default "discord"), and apiBase (override the Discord API base URL, e.g. to
pin an API version). Credentials are never read from the environment — the
caller passes them in.
dispatch.discord exposes four actions:
- Send a channel message —
send({ channelId, content?, embeds?, tts?, allowedMentions? }) - Send a direct message —
send({ user, content?, embeds?, tts?, allowedMentions? })(the client opens the DM channel for you) - List servers —
listGuilds()→DiscordGuild[](transparently pages through every guild the bot is in) - List channels —
listChannels({ guildId })→DiscordChannel[]
send requires content or at least one embed, and returns
{ kind: "channel" | "dm", id, channelId }.
Required Discord setup
Create a bot application in the Discord Developer Portal and use its bot token. The bot must be invited to a server before it can list or post there. Relevant gateway/permission notes:
- Sending messages needs the bot to have Send Messages (and View Channel) permission in the target channel.
- DMing a user generally requires a shared server with that user.
listGuilds()returns the servers the bot itself has been added to (GET /users/@me/guilds).
Exchange — @sedibyte/dispatch/exchange
exchange(options) returns a provider for createDispatch. It shares Teams'
Microsoft app-only authentication (delegated to @sedibyte/auth); choose exactly
one way to authenticate:
| Option | When to use |
| --- | --- |
| credentials: { tenantId, clientId, clientSecret } | The common case. Registers a Microsoft app-only provider with @sedibyte/auth and uses it. |
| authProviderName: string | Reuse a Microsoft provider you already registered with @sedibyte/auth (e.g. a named tenant). |
| graphClient: GraphClient | Inject an existing Graph client (advanced / testing). |
Extra options: namespace (key on the dispatch object, default "exchange"),
from (default sender mailbox, used when a call omits its own from), and
graphBase (override the Graph endpoint, e.g. a sovereign cloud).
dispatch.exchange.email(options) sends a message through the sender mailbox's
sendMail endpoint:
- Recipients —
to, and optionalcc/bcc/replyTo. Each accepts a single value or an array; a value is either a bare address ("[email protected]") or{ address, name? }. - Sender —
from(a mailbox id or userPrincipalName). Required, either per-call or via the provider's defaultfrom. - Content —
subject, plustextorhtml(htmlwins when both given). - Extras —
importance("low" | "normal" | "high"),saveToSentItems, andattachments({ name, content /* base64 */, contentType?, inline?, contentId? }).
It returns { kind: "email", from }.
Required Microsoft Graph permissions
Grant your app registration (admin consent) the application permission
Mail.Send, since Dispatch authenticates app-only through @sedibyte/auth. Note
that Mail.Send lets the app send as any mailbox in the tenant; scope this
down with an application access policy
if you need to restrict which mailboxes it can send from. See Microsoft's docs on
sending mail.
GitHub — @sedibyte/dispatch/github
github(options) returns a provider for createDispatch. Authentication is
delegated to @sedibyte/auth using a token you already hold. Choose exactly one
way to authenticate:
| Option | When to use |
| --- | --- |
| token: string | The common case. A classic or fine-grained personal access token, or an app installation token. Registers a GitHub API provider with @sedibyte/auth and uses it. |
| authProviderName: string | Reuse a GitHub provider you already registered with @sedibyte/auth. |
| client: GitHubClient | Inject an existing @sedibyte/auth GitHub client (advanced / testing). |
Extra options: namespace (key on the dispatch object, default "github"),
apiBaseUrl (for GitHub Enterprise Server, e.g. https://HOST/api/v3), and
userAgent. Credentials are never read from the environment — the caller passes
them in.
dispatch.github exposes read and write actions:
- Read a repo —
getRepo({ owner, repo })→GitHubRepo - List repos —
listRepos({ org | user | visibility, type?, limit? })(defaults to the authenticated user; transparently pages unlesslimitcaps it) - List commits —
listCommits({ owner, repo, sha?, path?, author?, since?, until?, limit? }) - Read a commit —
getCommit({ owner, repo, ref }) - List pull requests —
listPullRequests({ owner, repo, state?, head?, base?, limit? }) - Read a pull request —
getPullRequest({ owner, repo, number }) - Open a pull request —
createPullRequest({ owner, repo, title, head, base, body?, draft? }) - Push a commit —
commitFile({ owner, repo, path, message, content, branch?, sha? })creates or updates a single file (the Contents API), producing a commit; updating an existing file needs its current blobsha(fetch it withgetFileSha({ owner, repo, path, ref? })) - Comment —
comment({ owner, repo, issueNumber, body })on an issue or PR
List actions paginate automatically (per_page=100) unless you pass a limit.
Required GitHub token scopes
Grant the token only what the actions you use need. For a classic PAT, repo
covers reading and writing private repositories (commits, PRs, comments,
contents); public_repo suffices for public repos only. Fine-grained tokens
need Contents (read, or read/write for commitFile), Pull requests
(read, or read/write to open PRs), and Issues (read/write for comment).
Jira — @sedibyte/dispatch/jira
jira(options) returns a provider for createDispatch. Authentication is
delegated to @sedibyte/auth, which supports both Jira Cloud (email + API
token, sent as HTTP Basic) and Data Center / Server (a personal access
token, sent as Bearer). Choose exactly one way to authenticate:
| Option | When to use |
| --- | --- |
| { baseUrl, email, apiToken } | Jira Cloud. Registers a Basic-auth provider with @sedibyte/auth and uses it. |
| { baseUrl, token } | Jira Data Center / Server. Registers a Bearer (personal-access-token) provider. |
| authProviderName: string | Reuse a Jira provider you already registered with @sedibyte/auth. |
| client: JiraClient | Inject an existing @sedibyte/auth Jira client (advanced / testing). |
Extra options: namespace (key on the dispatch object, default "jira"), and
apiVersion (REST API version path segment, default "3" for Cloud; Data
Center commonly uses "2"). Credentials are never read from the environment.
dispatch.jira exposes read actions:
- Read an issue —
getIssue({ key, fields?, expand? })→JiraIssue(keyis an issue key like"PROJ-123"or a numeric id) - Search —
search({ jql, maxResults?, fields?, nextPageToken? })→ one page of{ issues, nextPageToken?, isLast? } - Search all —
searchAll({ jql, fields?, pageSize? })→JiraIssue[], following the cursor through every page for you
Create an API token at id.atlassian.com for Cloud, or a personal access token in your Jira profile for Data Center. The token only needs read access to the projects you query.
Architecture
src/
core/
dispatch.ts createDispatch — composes providers into a typed object
provider.ts DispatchProvider contract + defineProvider helper
errors.ts DispatchError
providers/
teams/
index.ts teams() factory (auth wiring via @sedibyte/auth)
client.ts createTeamsClient — the actions ({ send })
types.ts public option/result types
discord/
index.ts discord() factory (auth wiring via @sedibyte/auth)
client.ts createDiscordClient — the actions ({ send, listGuilds, listChannels })
types.ts public option/result types
exchange/
index.ts exchange() factory (auth wiring via @sedibyte/auth)
client.ts createExchangeClient — the actions ({ email })
types.ts public option/result types
github/
index.ts github() factory (auth wiring via @sedibyte/auth)
client.ts createGitHubClient — repos/commits/PRs (read + write)
types.ts public option/result types
jira/
index.ts jira() factory (auth wiring via @sedibyte/auth)
client.ts createJiraClient — the actions ({ getIssue, search, searchAll })
types.ts public option/result typesThe core knows nothing about any specific SaaS. A provider is just a
{ namespace, client } pair; createDispatch maps each namespace to its client
so dispatch.teams is typed as the Teams client.
Adding a provider
Providers are meant to be copy-pasteable. To add, say, Slack:
- Create
src/providers/slack/:client.ts—createSlackClient(deps): { send(...) }, the actions. Keep it dependency-injected (pass in the HTTP/auth client) so it's unit-testable without network.types.ts— public option/result types.index.ts— aslack(options)factory that wires up auth/config and returnsdefineProvider("slack", createSlackClient(...)).
- Add a
"./slack"entry toexportsinpackage.json. - Ship it with tests (see
src/providers/teams/*.test.tsfor the pattern).
Nothing in src/core changes.
// src/providers/slack/index.ts (sketch)
import { defineProvider, type DispatchProvider } from "@sedibyte/dispatch";
import { createSlackClient, type SlackClient } from "./client.js";
export function slack(options: SlackOptions): DispatchProvider<"slack", SlackClient> {
return defineProvider("slack", createSlackClient(/* ...wire up auth/config... */));
}Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest run
npm run build # tsc → dist/License
UNLICENSED — © Sedibyte.
