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:
@@ -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 }
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -12,6 +12,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case telegram
|
||||
case discord
|
||||
case slack
|
||||
case mattermost
|
||||
case signal
|
||||
case imessage
|
||||
case msteams
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user