feat: add Mattermost channel support

Add Mattermost as a supported messaging channel with bot API and WebSocket integration. Includes channel state tracking (tint, summary, details), multi-account support, and delivery target routing. Update documentation and tests to include Mattermost alongside existing channels.
This commit is contained in:
Dominic Damoah
2026-01-21 18:40:56 -05:00
parent fb164b321e
commit bf6df6d6b7
66 changed files with 2545 additions and 34 deletions

View File

@@ -164,6 +164,39 @@ export type SlackStatus = {
lastProbeAt?: number | null;
};
export type MattermostBot = {
id?: string | null;
username?: string | null;
};
export type MattermostProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
bot?: MattermostBot | null;
};
export type MattermostStatus = {
configured: boolean;
botTokenSource?: string | null;
running: boolean;
connected?: boolean | null;
lastConnectedAt?: number | null;
lastDisconnect?: {
at: number;
status?: number | null;
error?: string | null;
loggedOut?: boolean | null;
} | null;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
baseUrl?: string | null;
probe?: MattermostProbe | null;
lastProbeAt?: number | null;
};
export type SignalProbe = {
ok: boolean;
status?: number | null;
@@ -363,6 +396,7 @@ export type CronPayload =
| "telegram"
| "discord"
| "slack"
| "mattermost"
| "signal"
| "imessage"
| "msteams";

View File

@@ -0,0 +1,70 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { MattermostStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderMattermostCard(params: {
props: ChannelsProps;
mattermost?: MattermostStatus | null;
accountCountLabel: unknown;
}) {
const { props, mattermost, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Mattermost</div>
<div class="card-sub">Bot token + WebSocket status and configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${mattermost?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${mattermost?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${mattermost?.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${mattermost?.baseUrl || "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${mattermost?.lastStartAt ? formatAgo(mattermost.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${mattermost?.lastProbeAt ? formatAgo(mattermost.lastProbeAt) : "n/a"}</span>
</div>
</div>
${mattermost?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${mattermost.lastError}
</div>`
: nothing}
${mattermost?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${mattermost.probe.ok ? "ok" : "failed"} -
${mattermost.probe.status ?? ""} ${mattermost.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "mattermost", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -7,6 +7,7 @@ import type {
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -23,6 +24,7 @@ import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
import { renderIMessageCard } from "./channels.imessage";
import { renderMattermostCard } from "./channels.mattermost";
import { renderNostrCard } from "./channels.nostr";
import { renderSignalCard } from "./channels.signal";
import { renderSlackCard } from "./channels.slack";
@@ -39,6 +41,7 @@ export function renderChannels(props: ChannelsProps) {
| undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
const mattermost = (channels?.mattermost ?? null) as MattermostStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const nostr = (channels?.nostr ?? null) as NostrStatus | null;
@@ -62,6 +65,7 @@ export function renderChannels(props: ChannelsProps) {
telegram,
discord,
slack,
mattermost,
signal,
imessage,
nostr,
@@ -97,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"];
}
function renderChannel(
@@ -135,6 +139,12 @@ function renderChannel(
slack: data.slack,
accountCountLabel,
});
case "mattermost":
return renderMattermostCard({
props,
mattermost: data.mattermost,
accountCountLabel,
});
case "signal":
return renderSignalCard({
props,

View File

@@ -4,6 +4,7 @@ import type {
ConfigUiHints,
DiscordStatus,
IMessageStatus,
MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -53,6 +54,7 @@ export type ChannelsChannelData = {
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
mattermost?: MattermostStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
nostr?: NostrStatus | null;