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

@@ -40,6 +40,17 @@ extension ChannelsSettings {
return .orange
}
var mattermostTint: Color {
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.connected == true { return .green }
if status.running { return .orange }
return .orange
}
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
@@ -85,6 +96,15 @@ extension ChannelsSettings {
return "Configured"
}
var mattermostSummary: String {
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.connected == true { return "Connected" }
if status.running { return "Running" }
return "Configured"
}
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
@@ -193,6 +213,38 @@ extension ChannelsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var mattermostDetails: String? {
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.botTokenSource {
lines.append("Token source: \(source)")
}
if let baseUrl = status.baseUrl, !baseUrl.isEmpty {
lines.append("Base URL: \(baseUrl)")
}
if let probe = status.probe {
if probe.ok {
if let name = probe.bot?.username {
lines.append("Bot: @\(name)")
}
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil }
@@ -244,7 +296,7 @@ extension ChannelsSettings {
}
var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
let fallback = ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
@@ -307,6 +359,8 @@ extension ChannelsSettings {
return self.telegramTint
case "discord":
return self.discordTint
case "mattermost":
return self.mattermostTint
case "signal":
return self.signalTint
case "imessage":
@@ -326,6 +380,8 @@ extension ChannelsSettings {
return self.telegramSummary
case "discord":
return self.discordSummary
case "mattermost":
return self.mattermostSummary
case "signal":
return self.signalSummary
case "imessage":
@@ -345,6 +401,8 @@ extension ChannelsSettings {
return self.telegramDetails
case "discord":
return self.discordDetails
case "mattermost":
return self.mattermostDetails
case "signal":
return self.signalDetails
case "imessage":
@@ -377,6 +435,10 @@ extension ChannelsSettings {
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case "mattermost":
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return nil }
return self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt)
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
@@ -411,6 +473,10 @@ extension ChannelsSettings {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "mattermost":
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }

View File

@@ -85,6 +85,40 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double?
}
struct MattermostBot: Codable {
let id: String?
let username: String?
}
struct MattermostProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: MattermostBot?
}
struct MattermostDisconnect: Codable {
let at: Double
let status: Int?
let error: String?
}
struct MattermostStatus: Codable {
let configured: Bool
let botTokenSource: String?
let running: Bool
let connected: Bool?
let lastConnectedAt: Double?
let lastDisconnect: MattermostDisconnect?
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let baseUrl: String?
let probe: MattermostProbe?
let lastProbeAt: Double?
}
struct SignalProbe: Codable {
let ok: Bool
let status: Int?

View File

@@ -12,6 +12,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case telegram
case discord
case slack
case mattermost
case signal
case imessage
case msteams

View File

@@ -12,10 +12,11 @@ struct ChannelsSettingsSmokeTests {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"mattermost": "Mattermost",
"signal": "Signal",
"imessage": "iMessage",
],
@@ -57,6 +58,21 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"mattermost": SnapshotAnyCodable([
"configured": true,
"botTokenSource": "env",
"running": true,
"connected": true,
"baseUrl": "https://chat.example.com",
"lastStartAt": 1_700_000_000_000,
"probe": [
"ok": true,
"status": 200,
"elapsedMs": 95,
"bot": ["id": "bot-123", "username": "clawdbot"],
],
"lastProbeAt": 1_700_000_050_000,
]),
"signal": SnapshotAnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
@@ -82,6 +98,7 @@ struct ChannelsSettingsSmokeTests {
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"mattermost": "default",
"signal": "default",
"imessage": "default",
])
@@ -98,10 +115,11 @@ struct ChannelsSettingsSmokeTests {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"mattermost": "Mattermost",
"signal": "Signal",
"imessage": "iMessage",
],
@@ -128,6 +146,19 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_100_000,
]),
"mattermost": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "bot token missing",
"baseUrl": "https://chat.example.com",
"probe": [
"ok": false,
"status": 401,
"error": "unauthorized",
"elapsedMs": 110,
],
"lastProbeAt": 1_700_000_150_000,
]),
"signal": SnapshotAnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
@@ -154,6 +185,7 @@ struct ChannelsSettingsSmokeTests {
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"mattermost": "default",
"signal": "default",
"imessage": "default",
])

View File

@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.mattermost.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}