fix: harden Mattermost plugin gating (#1428) (thanks @damoahdominic)
This commit is contained in:
@@ -31,6 +31,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||||
- Agents: make tool summaries more readable and only show optional params when set.
|
- Agents: make tool summaries more readable and only show optional params when set.
|
||||||
|
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||||
|
|
||||||
|
|||||||
@@ -40,17 +40,6 @@ extension ChannelsSettings {
|
|||||||
return .orange
|
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 {
|
var signalTint: Color {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
@@ -96,15 +85,6 @@ extension ChannelsSettings {
|
|||||||
return "Configured"
|
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 {
|
var signalSummary: String {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
@@ -213,38 +193,6 @@ extension ChannelsSettings {
|
|||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
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? {
|
var signalDetails: String? {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
@@ -296,7 +244,7 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ChannelItem] {
|
var orderedChannels: [ChannelItem] {
|
||||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage"]
|
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
||||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||||
let channels = order.enumerated().map { index, id in
|
let channels = order.enumerated().map { index, id in
|
||||||
ChannelItem(
|
ChannelItem(
|
||||||
@@ -359,8 +307,6 @@ extension ChannelsSettings {
|
|||||||
return self.telegramTint
|
return self.telegramTint
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordTint
|
return self.discordTint
|
||||||
case "mattermost":
|
|
||||||
return self.mattermostTint
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalTint
|
return self.signalTint
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -380,8 +326,6 @@ extension ChannelsSettings {
|
|||||||
return self.telegramSummary
|
return self.telegramSummary
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordSummary
|
return self.discordSummary
|
||||||
case "mattermost":
|
|
||||||
return self.mattermostSummary
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalSummary
|
return self.signalSummary
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -401,8 +345,6 @@ extension ChannelsSettings {
|
|||||||
return self.telegramDetails
|
return self.telegramDetails
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordDetails
|
return self.discordDetails
|
||||||
case "mattermost":
|
|
||||||
return self.mattermostDetails
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalDetails
|
return self.signalDetails
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -435,10 +377,6 @@ extension ChannelsSettings {
|
|||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||||
.lastProbeAt)
|
.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":
|
case "signal":
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||||
@@ -473,10 +411,6 @@ extension ChannelsSettings {
|
|||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == 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":
|
case "signal":
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|||||||
@@ -85,40 +85,6 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let lastProbeAt: Double?
|
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 {
|
struct SignalProbe: Codable {
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
let status: Int?
|
let status: Int?
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
case telegram
|
case telegram
|
||||||
case discord
|
case discord
|
||||||
case slack
|
case slack
|
||||||
case mattermost
|
|
||||||
case signal
|
case signal
|
||||||
case imessage
|
case imessage
|
||||||
case msteams
|
case msteams
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
let store = ChannelsStore(isPreview: true)
|
let store = ChannelsStore(isPreview: true)
|
||||||
store.snapshot = ChannelsStatusSnapshot(
|
store.snapshot = ChannelsStatusSnapshot(
|
||||||
ts: 1_700_000_000_000,
|
ts: 1_700_000_000_000,
|
||||||
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
|
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||||
channelLabels: [
|
channelLabels: [
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"mattermost": "Mattermost",
|
|
||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
@@ -58,21 +57,6 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_050_000,
|
"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([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
@@ -98,7 +82,6 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
channelDefaultAccountId: [
|
channelDefaultAccountId: [
|
||||||
"whatsapp": "default",
|
"whatsapp": "default",
|
||||||
"telegram": "default",
|
"telegram": "default",
|
||||||
"mattermost": "default",
|
|
||||||
"signal": "default",
|
"signal": "default",
|
||||||
"imessage": "default",
|
"imessage": "default",
|
||||||
])
|
])
|
||||||
@@ -115,11 +98,10 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
let store = ChannelsStore(isPreview: true)
|
let store = ChannelsStore(isPreview: true)
|
||||||
store.snapshot = ChannelsStatusSnapshot(
|
store.snapshot = ChannelsStatusSnapshot(
|
||||||
ts: 1_700_000_000_000,
|
ts: 1_700_000_000_000,
|
||||||
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
|
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||||
channelLabels: [
|
channelLabels: [
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"mattermost": "Mattermost",
|
|
||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
@@ -146,19 +128,6 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_100_000,
|
"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([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
@@ -185,7 +154,6 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
channelDefaultAccountId: [
|
channelDefaultAccountId: [
|
||||||
"whatsapp": "default",
|
"whatsapp": "default",
|
||||||
"telegram": "default",
|
"telegram": "default",
|
||||||
"mattermost": "default",
|
|
||||||
"signal": "default",
|
"signal": "default",
|
||||||
"imessage": "default",
|
"imessage": "default",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.mattermost.shouldDeliver(true) == true)
|
|
||||||
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ Resolution priority:
|
|||||||
|
|
||||||
### Delivery (channel + target)
|
### Delivery (channel + target)
|
||||||
Isolated jobs can deliver output to a channel. The job payload can specify:
|
Isolated jobs can deliver output to a channel. The job payload can specify:
|
||||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` / `signal` / `imessage` / `last`
|
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
|
||||||
- `to`: channel-specific recipient target
|
- `to`: channel-specific recipient target
|
||||||
|
|
||||||
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
||||||
@@ -133,7 +133,7 @@ Delivery notes:
|
|||||||
- Use `deliver: false` to keep output internal even if a `to` is present.
|
- Use `deliver: false` to keep output internal even if a `to` is present.
|
||||||
|
|
||||||
Target format reminders:
|
Target format reminders:
|
||||||
- Slack/Discord/Mattermost targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||||
- Telegram topics should use the `:topic:` form (see below).
|
- Telegram topics should use the `:topic:` form (see below).
|
||||||
|
|
||||||
#### Telegram delivery targets (topics / forum threads)
|
#### Telegram delivery targets (topics / forum threads)
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ Payload:
|
|||||||
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
||||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost`, `signal`, `imessage`, `msteams`. Defaults to `last`.
|
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost, conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs.
|
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
||||||
|
|||||||
@@ -5,12 +5,33 @@ read_when:
|
|||||||
- Debugging Mattermost routing
|
- Debugging Mattermost routing
|
||||||
---
|
---
|
||||||
|
|
||||||
# Mattermost
|
# Mattermost (plugin)
|
||||||
|
|
||||||
|
Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported.
|
||||||
|
|
||||||
|
## Plugin required
|
||||||
|
Mattermost ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
|
Install via CLI (npm registry):
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/mattermost
|
||||||
|
```
|
||||||
|
|
||||||
|
Local checkout (when running from a git repo):
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/mattermost
|
||||||
|
```
|
||||||
|
|
||||||
|
If you choose Mattermost during configure/onboarding and a git checkout is detected,
|
||||||
|
Clawdbot will offer the local install path automatically.
|
||||||
|
|
||||||
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
## Quick setup
|
## Quick setup
|
||||||
1) Create a Mattermost bot account and copy the **bot token**.
|
1) Install the Mattermost plugin.
|
||||||
2) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
|
2) Create a Mattermost bot account and copy the **bot token**.
|
||||||
3) Configure Clawdbot and start the gateway.
|
3) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
|
||||||
|
4) Configure Clawdbot and start the gateway.
|
||||||
|
|
||||||
Minimal config:
|
Minimal config:
|
||||||
```json5
|
```json5
|
||||||
@@ -19,7 +40,8 @@ Minimal config:
|
|||||||
mattermost: {
|
mattermost: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
botToken: "mm-token",
|
botToken: "mm-token",
|
||||||
baseUrl: "https://chat.example.com"
|
baseUrl: "https://chat.example.com",
|
||||||
|
dmPolicy: "pairing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +78,18 @@ Notes:
|
|||||||
- `onchar` still responds to explicit @mentions.
|
- `onchar` still responds to explicit @mentions.
|
||||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||||
|
|
||||||
|
## Access control (DMs)
|
||||||
|
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
|
||||||
|
- Approve via:
|
||||||
|
- `clawdbot pairing list mattermost`
|
||||||
|
- `clawdbot pairing approve mattermost <CODE>`
|
||||||
|
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
|
||||||
|
|
||||||
|
## Channels (groups)
|
||||||
|
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
|
||||||
|
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`).
|
||||||
|
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
|
||||||
|
|
||||||
## Targets for outbound delivery
|
## Targets for outbound delivery
|
||||||
Use these target formats with `clawdbot message send` or cron/webhooks:
|
Use these target formats with `clawdbot message send` or cron/webhooks:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
|
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage)
|
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage)
|
||||||
- You want to check channel status or tail channel logs
|
- You want to check channel status or tail channel logs
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ Options:
|
|||||||
## Channel helpers
|
## Channel helpers
|
||||||
|
|
||||||
### `channels`
|
### `channels`
|
||||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage/MS Teams).
|
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ read_when:
|
|||||||
# `clawdbot message`
|
# `clawdbot message`
|
||||||
|
|
||||||
Single outbound command for sending messages and channel actions
|
Single outbound command for sending messages and channel actions
|
||||||
(Discord/Slack/Mattermost/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -19,14 +19,14 @@ clawdbot message <subcommand> [flags]
|
|||||||
Channel selection:
|
Channel selection:
|
||||||
- `--channel` required if more than one channel is configured.
|
- `--channel` required if more than one channel is configured.
|
||||||
- If exactly one channel is configured, it becomes the default.
|
- If exactly one channel is configured, it becomes the default.
|
||||||
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
|
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
|
||||||
|
|
||||||
Target formats (`--target`):
|
Target formats (`--target`):
|
||||||
- WhatsApp: E.164 or group JID
|
- WhatsApp: E.164 or group JID
|
||||||
- Telegram: chat id or `@username`
|
- Telegram: chat id or `@username`
|
||||||
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
|
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
|
||||||
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
||||||
- Mattermost: `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
||||||
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
||||||
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
||||||
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||||
@@ -50,7 +50,7 @@ Name lookup:
|
|||||||
### Core
|
### Core
|
||||||
|
|
||||||
- `send`
|
- `send`
|
||||||
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage/MS Teams
|
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
|
||||||
- Required: `--target`, plus `--message` or `--media`
|
- Required: `--target`, plus `--message` or `--media`
|
||||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||||
|
|||||||
@@ -1206,6 +1206,9 @@ Slack action groups (gate `slack` tool actions):
|
|||||||
|
|
||||||
### `channels.mattermost` (bot token)
|
### `channels.mattermost` (bot token)
|
||||||
|
|
||||||
|
Mattermost ships as a plugin and is not bundled with the core install.
|
||||||
|
Install it first: `clawdbot plugins install @clawdbot/mattermost` (or `./extensions/mattermost` from a git checkout).
|
||||||
|
|
||||||
Mattermost requires a bot token plus the base URL for your server:
|
Mattermost requires a bot token plus the base URL for your server:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -1215,6 +1218,7 @@ Mattermost requires a bot token plus the base URL for your server:
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
botToken: "mm-token",
|
botToken: "mm-token",
|
||||||
baseUrl: "https://chat.example.com",
|
baseUrl: "https://chat.example.com",
|
||||||
|
dmPolicy: "pairing",
|
||||||
chatmode: "oncall", // oncall | onmessage | onchar
|
chatmode: "oncall", // oncall | onmessage | onchar
|
||||||
oncharPrefixes: [">", "!"],
|
oncharPrefixes: [">", "!"],
|
||||||
textChunkLimit: 4000
|
textChunkLimit: 4000
|
||||||
@@ -1230,6 +1234,11 @@ Chat modes:
|
|||||||
- `onmessage`: respond to every channel message.
|
- `onmessage`: respond to every channel message.
|
||||||
- `onchar`: respond when a message starts with a trigger prefix (`channels.mattermost.oncharPrefixes`, default `[">", "!"]`).
|
- `onchar`: respond when a message starts with a trigger prefix (`channels.mattermost.oncharPrefixes`, default `[">", "!"]`).
|
||||||
|
|
||||||
|
Access control:
|
||||||
|
- Default DMs: `channels.mattermost.dmPolicy="pairing"` (unknown senders get a pairing code).
|
||||||
|
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
|
||||||
|
- Groups: `channels.mattermost.groupPolicy="allowlist"` by default (mention-gated). Use `channels.mattermost.groupAllowFrom` to restrict senders.
|
||||||
|
|
||||||
Multi-account support lives under `channels.mattermost.accounts` (see the multi-account section above). Env vars only apply to the default account.
|
Multi-account support lives under `channels.mattermost.accounts` (see the multi-account section above). Env vars only apply to the default account.
|
||||||
Use `channel:<id>` or `user:<id>` (or `@username`) when specifying delivery targets; bare ids are treated as channel ids.
|
Use `channel:<id>` or `user:<id>` (or `@username`) when specifying delivery targets; bare ids are treated as channel ids.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ read_when:
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Any OS + WhatsApp/Telegram/Discord/Mattermost/iMessage gateway for AI agents (Pi).</strong><br />
|
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
|
||||||
|
Plugins add Mattermost and more.
|
||||||
Send a message, get an agent response — from your pocket.
|
Send a message, get an agent response — from your pocket.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ read_when:
|
|||||||
<a href="/start/clawd">Clawdbot assistant setup</a>
|
<a href="/start/clawd">Clawdbot assistant setup</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), Mattermost (Bot API + WebSocket), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
|
Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono). Plugins add Mattermost (Bot API + WebSocket) and more.
|
||||||
Clawdbot also powers [Clawd](https://clawd.me), the space‑lobster assistant.
|
Clawdbot also powers [Clawd](https://clawd.me), the space‑lobster assistant.
|
||||||
|
|
||||||
## Start here
|
## Start here
|
||||||
@@ -44,7 +45,7 @@ Remote access: [Web surfaces](/web) and [Tailscale](/gateway/tailscale)
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
WhatsApp / Telegram / Discord / Mattermost
|
WhatsApp / Telegram / Discord / iMessage (+ plugins)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────┐
|
┌───────────────────────────┐
|
||||||
@@ -79,7 +80,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
|
|||||||
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
|
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
|
||||||
- ✈️ **Telegram Bot** — DMs + groups via grammY
|
- ✈️ **Telegram Bot** — DMs + groups via grammY
|
||||||
- 🎮 **Discord Bot** — DMs + guild channels via channels.discord.js
|
- 🎮 **Discord Bot** — DMs + guild channels via channels.discord.js
|
||||||
- 🧩 **Mattermost Bot** — Bot token + WebSocket events
|
- 🧩 **Mattermost Bot (plugin)** — Bot token + WebSocket events
|
||||||
- 💬 **iMessage** — Local imsg CLI integration (macOS)
|
- 💬 **iMessage** — Local imsg CLI integration (macOS)
|
||||||
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
|
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
|
||||||
- ⏱️ **Streaming + chunking** — Block streaming + Telegram draft streaming details ([/concepts/streaming](/concepts/streaming))
|
- ⏱️ **Streaming + chunking** — Block streaming + Telegram draft streaming details ([/concepts/streaming](/concepts/streaming))
|
||||||
@@ -191,7 +192,7 @@ Example:
|
|||||||
- [Control UI (browser)](/web/control-ui)
|
- [Control UI (browser)](/web/control-ui)
|
||||||
- [Telegram](/channels/telegram)
|
- [Telegram](/channels/telegram)
|
||||||
- [Discord](/channels/discord)
|
- [Discord](/channels/discord)
|
||||||
- [Mattermost](/channels/mattermost)
|
- [Mattermost (plugin)](/channels/mattermost)
|
||||||
- [iMessage](/channels/imessage)
|
- [iMessage](/channels/imessage)
|
||||||
- [Groups](/concepts/groups)
|
- [Groups](/concepts/groups)
|
||||||
- [WhatsApp group messages](/concepts/group-messages)
|
- [WhatsApp group messages](/concepts/group-messages)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ read_when:
|
|||||||
Clawdbot can use many LLM providers. Pick a provider, authenticate, then set the
|
Clawdbot can use many LLM providers. Pick a provider, authenticate, then set the
|
||||||
default model as `provider/model`.
|
default model as `provider/model`.
|
||||||
|
|
||||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost/etc.)? See [Channels](/channels).
|
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Building a personal assistant with Clawdbot (Clawd-style)
|
# Building a personal assistant with Clawdbot (Clawd-style)
|
||||||
|
|
||||||
Clawdbot is a WhatsApp + Telegram + Discord + Mattermost gateway for **Pi** agents. This guide is the "personal assistant" setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
Clawdbot is a WhatsApp + Telegram + Discord + iMessage gateway for **Pi** agents. Plugins add Mattermost. This guide is the "personal assistant" setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
||||||
|
|
||||||
## ⚠️ Safety first
|
## ⚠️ Safety first
|
||||||
|
|
||||||
You’re putting an agent in a position to:
|
You’re putting an agent in a position to:
|
||||||
- run commands on your machine (depending on your Pi tool setup)
|
- run commands on your machine (depending on your Pi tool setup)
|
||||||
- read/write files in your workspace
|
- read/write files in your workspace
|
||||||
- send messages back out via WhatsApp/Telegram/Discord/Mattermost
|
- send messages back out via WhatsApp/Telegram/Discord/Mattermost (plugin)
|
||||||
|
|
||||||
Start conservative:
|
Start conservative:
|
||||||
- Always set `channels.whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
- Always set `channels.whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
|
|
||||||
### What is Clawdbot, in one paragraph?
|
### What is Clawdbot, in one paragraph?
|
||||||
|
|
||||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
|
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
|
||||||
|
|
||||||
## Quick start and first-run setup
|
## Quick start and first-run setup
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo
|
|||||||
- **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported)
|
- **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported)
|
||||||
- **Workspace** location + bootstrap files
|
- **Workspace** location + bootstrap files
|
||||||
- **Gateway settings** (bind/port/auth/tailscale)
|
- **Gateway settings** (bind/port/auth/tailscale)
|
||||||
- **Providers** (WhatsApp, Telegram, Discord, Mattermost, Signal, iMessage)
|
- **Providers** (WhatsApp, Telegram, Discord, Mattermost (plugin), Signal, iMessage)
|
||||||
- **Daemon install** (LaunchAgent on macOS; systemd user unit on Linux/WSL2)
|
- **Daemon install** (LaunchAgent on macOS; systemd user unit on Linux/WSL2)
|
||||||
- **Health checks** and **skills** selection
|
- **Health checks** and **skills** selection
|
||||||
|
|
||||||
@@ -363,7 +363,7 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
|
|||||||
- **Pros:** always‑on, stable network, no laptop sleep issues, easier to keep running.
|
- **Pros:** always‑on, stable network, no laptop sleep issues, easier to keep running.
|
||||||
- **Cons:** often run headless (use screenshots), remote file access only, you must SSH for updates.
|
- **Cons:** often run headless (use screenshots), remote file access only, you must SSH for updates.
|
||||||
|
|
||||||
**Clawdbot-specific note:** WhatsApp/Telegram/Slack/Mattermost/Discord all work fine from a VPS. The only real trade-off is **headless browser** vs a visible window. See [Browser](/tools/browser).
|
**Clawdbot-specific note:** WhatsApp/Telegram/Slack/Mattermost (plugin)/Discord all work fine from a VPS. The only real trade-off is **headless browser** vs a visible window. See [Browser](/tools/browser).
|
||||||
|
|
||||||
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
|
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Goal: go from **zero** → **first working chat** (with sane defaults) as quickl
|
|||||||
Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up:
|
Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up:
|
||||||
- model/auth (OAuth recommended)
|
- model/auth (OAuth recommended)
|
||||||
- gateway settings
|
- gateway settings
|
||||||
- channels (WhatsApp/Telegram/Discord/Mattermost/...)
|
- channels (WhatsApp/Telegram/Discord/Mattermost (plugin)/...)
|
||||||
- pairing defaults (secure DMs)
|
- pairing defaults (secure DMs)
|
||||||
- workspace bootstrap + skills
|
- workspace bootstrap + skills
|
||||||
- optional background service
|
- optional background service
|
||||||
@@ -80,7 +80,7 @@ clawdbot onboard --install-daemon
|
|||||||
What you’ll choose:
|
What you’ll choose:
|
||||||
- **Local vs Remote** gateway
|
- **Local vs Remote** gateway
|
||||||
- **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported.
|
- **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported.
|
||||||
- **Providers**: WhatsApp QR login, Telegram/Discord/Mattermost bot tokens, etc.
|
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, Mattermost plugin tokens, etc.
|
||||||
- **Daemon**: background install (launchd/systemd; WSL2 uses systemd)
|
- **Daemon**: background install (launchd/systemd; WSL2 uses systemd)
|
||||||
- **Runtime**: Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
|
- **Runtime**: Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
|
||||||
- **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`.
|
- **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`.
|
||||||
@@ -140,7 +140,7 @@ WhatsApp doc: [WhatsApp](/channels/whatsapp)
|
|||||||
The wizard can write tokens/config for you. If you prefer manual config, start with:
|
The wizard can write tokens/config for you. If you prefer manual config, start with:
|
||||||
- Telegram: [Telegram](/channels/telegram)
|
- Telegram: [Telegram](/channels/telegram)
|
||||||
- Discord: [Discord](/channels/discord)
|
- Discord: [Discord](/channels/discord)
|
||||||
- Mattermost: [Mattermost](/channels/mattermost)
|
- Mattermost (plugin): [Mattermost](/channels/mattermost)
|
||||||
|
|
||||||
**Telegram DM tip:** your first DM returns a pairing code. Approve it (see next step) or the bot won’t respond.
|
**Telegram DM tip:** your first DM returns a pairing code. Approve it (see next step) or the bot won’t respond.
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
- [Telegram (grammY notes)](/channels/grammy)
|
- [Telegram (grammY notes)](/channels/grammy)
|
||||||
- [Slack](/channels/slack)
|
- [Slack](/channels/slack)
|
||||||
- [Discord](/channels/discord)
|
- [Discord](/channels/discord)
|
||||||
- [Mattermost](/channels/mattermost)
|
- [Mattermost](/channels/mattermost) (plugin)
|
||||||
- [Signal](/channels/signal)
|
- [Signal](/channels/signal)
|
||||||
- [iMessage](/channels/imessage)
|
- [iMessage](/channels/imessage)
|
||||||
- [Location parsing](/channels/location)
|
- [Location parsing](/channels/location)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
|||||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)
|
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)
|
||||||
- Workspace location + bootstrap files
|
- Workspace location + bootstrap files
|
||||||
- Gateway settings (port/bind/auth/tailscale)
|
- Gateway settings (port/bind/auth/tailscale)
|
||||||
- Providers (Telegram, WhatsApp, Discord, Mattermost, Signal)
|
- Providers (Telegram, WhatsApp, Discord, Mattermost (plugin), Signal)
|
||||||
- Daemon install (LaunchAgent / systemd user unit)
|
- Daemon install (LaunchAgent / systemd user unit)
|
||||||
- Health check
|
- Health check
|
||||||
- Skills (recommended)
|
- Skills (recommended)
|
||||||
@@ -117,7 +117,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
- WhatsApp: optional QR login.
|
- WhatsApp: optional QR login.
|
||||||
- Telegram: bot token.
|
- Telegram: bot token.
|
||||||
- Discord: bot token.
|
- Discord: bot token.
|
||||||
- Mattermost: bot token + base URL.
|
- Mattermost (plugin): bot token + base URL.
|
||||||
- Signal: optional `signal-cli` install + account config.
|
- Signal: optional `signal-cli` install + account config.
|
||||||
- iMessage: local `imsg` CLI path + DB access.
|
- iMessage: local `imsg` CLI path + DB access.
|
||||||
- DM security: default is pairing. First DM sends a code; approve via `clawdbot pairing approve <channel> <code>` or use allowlists.
|
- DM security: default is pairing. First DM sends a code; approve via `clawdbot pairing approve <channel> <code>` or use allowlists.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
|||||||
## What it can do (today)
|
## What it can do (today)
|
||||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
||||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||||
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
||||||
- Instances: presence list + refresh (`system-presence`)
|
- Instances: presence list + refresh (`system-presence`)
|
||||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||||
|
|||||||
43
extensions/mattermost/src/channel.test.ts
Normal file
43
extensions/mattermost/src/channel.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { mattermostPlugin } from "./channel.js";
|
||||||
|
|
||||||
|
describe("mattermostPlugin", () => {
|
||||||
|
describe("messaging", () => {
|
||||||
|
it("keeps @username targets", () => {
|
||||||
|
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||||
|
if (!normalize) return;
|
||||||
|
|
||||||
|
expect(normalize("@Alice")).toBe("@Alice");
|
||||||
|
expect(normalize("@alice")).toBe("@alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes mattermost: prefix to user:", () => {
|
||||||
|
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||||
|
if (!normalize) return;
|
||||||
|
|
||||||
|
expect(normalize("mattermost:USER123")).toBe("user:USER123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pairing", () => {
|
||||||
|
it("normalizes allowlist entries", () => {
|
||||||
|
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
|
||||||
|
if (!normalize) return;
|
||||||
|
|
||||||
|
expect(normalize("@Alice")).toBe("alice");
|
||||||
|
expect(normalize("user:USER123")).toBe("user123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config", () => {
|
||||||
|
it("formats allowFrom entries", () => {
|
||||||
|
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom;
|
||||||
|
|
||||||
|
const formatted = formatAllowFrom({
|
||||||
|
allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"],
|
||||||
|
});
|
||||||
|
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
@@ -38,14 +39,40 @@ const meta = {
|
|||||||
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
|
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
|
||||||
systemImage: "bubble.left.and.bubble.right",
|
systemImage: "bubble.left.and.bubble.right",
|
||||||
order: 65,
|
order: 65,
|
||||||
|
quickstartAllowFrom: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function normalizeAllowEntry(entry: string): string {
|
||||||
|
return entry
|
||||||
|
.trim()
|
||||||
|
.replace(/^(mattermost|user):/i, "")
|
||||||
|
.replace(/^@/, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAllowEntry(entry: string): string {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.startsWith("@")) {
|
||||||
|
const username = trimmed.slice(1).trim();
|
||||||
|
return username ? `@${username.toLowerCase()}` : "";
|
||||||
|
}
|
||||||
|
return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||||
id: "mattermost",
|
id: "mattermost",
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
...meta,
|
||||||
},
|
},
|
||||||
onboarding: mattermostOnboardingAdapter,
|
onboarding: mattermostOnboardingAdapter,
|
||||||
|
pairing: {
|
||||||
|
idLabel: "mattermostUserId",
|
||||||
|
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
|
||||||
|
notifyApproval: async ({ id }) => {
|
||||||
|
console.log(`[mattermost] User ${id} approved for pairing`);
|
||||||
|
},
|
||||||
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
chatTypes: ["direct", "channel", "group", "thread"],
|
chatTypes: ["direct", "channel", "group", "thread"],
|
||||||
threads: true,
|
threads: true,
|
||||||
@@ -84,6 +111,39 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
botTokenSource: account.botTokenSource,
|
botTokenSource: account.botTokenSource,
|
||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
}),
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||||
|
String(entry),
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => formatAllowEntry(String(entry)))
|
||||||
|
.filter(Boolean),
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]);
|
||||||
|
const basePath = useAccountPath
|
||||||
|
? `channels.mattermost.accounts.${resolvedAccountId}.`
|
||||||
|
: "channels.mattermost.";
|
||||||
|
return {
|
||||||
|
policy: account.config.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: `${basePath}dmPolicy`,
|
||||||
|
allowFromPath: basePath,
|
||||||
|
approveHint: formatPairingApproveHint("mattermost"),
|
||||||
|
normalizeEntry: (raw) => normalizeAllowEntry(raw),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
collectWarnings: ({ account, cfg }) => {
|
||||||
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
|
if (groupPolicy !== "open") return [];
|
||||||
|
return [
|
||||||
|
`- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
|
||||||
|
];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||||
@@ -105,23 +165,21 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: new Error(
|
error: new Error(
|
||||||
"Delivering to Mattermost requires --to <channelId|user:ID|channel:ID>",
|
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||||
const send = deps?.sendMattermost ?? sendMessageMattermost;
|
const result = await sendMessageMattermost(to, text, {
|
||||||
const result = await send(to, text, {
|
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "mattermost", ...result };
|
return { channel: "mattermost", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||||
const send = deps?.sendMattermost ?? sendMessageMattermost;
|
const result = await sendMessageMattermost(to, text, {
|
||||||
const result = await send(to, text, {
|
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk";
|
import {
|
||||||
|
BlockStreamingCoalesceSchema,
|
||||||
|
DmPolicySchema,
|
||||||
|
GroupPolicySchema,
|
||||||
|
requireOpenAllowFrom,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
const MattermostAccountSchema = z
|
const MattermostAccountSchemaBase = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
@@ -13,12 +18,36 @@ const MattermostAccountSchema = z
|
|||||||
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
||||||
oncharPrefixes: z.array(z.string()).optional(),
|
oncharPrefixes: z.array(z.string()).optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const MattermostConfigSchema = MattermostAccountSchema.extend({
|
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
|
||||||
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
requireOpenAllowFrom({
|
||||||
|
policy: value.dmPolicy,
|
||||||
|
allowFrom: value.allowFrom,
|
||||||
|
ctx,
|
||||||
|
path: ["allowFrom"],
|
||||||
|
message:
|
||||||
|
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
|
||||||
|
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
requireOpenAllowFrom({
|
||||||
|
policy: value.dmPolicy,
|
||||||
|
allowFrom: value.allowFrom,
|
||||||
|
ctx,
|
||||||
|
path: ["allowFrom"],
|
||||||
|
message:
|
||||||
|
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
|
|||||||
});
|
});
|
||||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
if (typeof account.requireMention === "boolean") return account.requireMention;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
|
|||||||
return listMattermostAccountIds(cfg)
|
return listMattermostAccountIds(cfg)
|
||||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||||
.filter((account) => account.enabled);
|
.filter((account) => account.enabled);
|
||||||
}
|
}
|
||||||
@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
|
|||||||
throw new Error("Mattermost file upload failed");
|
throw new Error("Mattermost file upload failed");
|
||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
|
|||||||
? `${params.baseSessionKey}:thread:${threadId}`
|
? `${params.baseSessionKey}:thread:${threadId}`
|
||||||
: params.baseSessionKey;
|
: params.baseSessionKey;
|
||||||
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||||
}
|
}
|
||||||
@@ -141,6 +141,39 @@ function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" |
|
|||||||
return "channel";
|
return "channel";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAllowEntry(entry: string): string {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed === "*") return "*";
|
||||||
|
return trimmed
|
||||||
|
.replace(/^(mattermost|user):/i, "")
|
||||||
|
.replace(/^@/, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowList(entries: Array<string | number>): string[] {
|
||||||
|
const normalized = entries
|
||||||
|
.map((entry) => normalizeAllowEntry(String(entry)))
|
||||||
|
.filter(Boolean);
|
||||||
|
return Array.from(new Set(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSenderAllowed(params: {
|
||||||
|
senderId: string;
|
||||||
|
senderName?: string;
|
||||||
|
allowFrom: string[];
|
||||||
|
}): boolean {
|
||||||
|
const allowFrom = params.allowFrom;
|
||||||
|
if (allowFrom.length === 0) return false;
|
||||||
|
if (allowFrom.includes("*")) return true;
|
||||||
|
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
||||||
|
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
||||||
|
return allowFrom.some(
|
||||||
|
(entry) =>
|
||||||
|
entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type MattermostMediaInfo = {
|
type MattermostMediaInfo = {
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@@ -346,6 +379,122 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const kind = channelKind(channelType);
|
const kind = channelKind(channelType);
|
||||||
const chatType = channelChatType(kind);
|
const chatType = channelChatType(kind);
|
||||||
|
|
||||||
|
const senderName =
|
||||||
|
payload.data?.sender_name?.trim() ||
|
||||||
|
(await resolveUserInfo(senderId))?.username?.trim() ||
|
||||||
|
senderId;
|
||||||
|
const rawText = post.message?.trim() || "";
|
||||||
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||||
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
|
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
||||||
|
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
|
||||||
|
const storeAllowFrom = normalizeAllowList(
|
||||||
|
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||||
|
);
|
||||||
|
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
|
||||||
|
const effectiveGroupAllowFrom = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
||||||
|
...storeAllowFrom,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||||
|
cfg,
|
||||||
|
surface: "mattermost",
|
||||||
|
});
|
||||||
|
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
|
||||||
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
|
const senderAllowedForCommands = isSenderAllowed({
|
||||||
|
senderId,
|
||||||
|
senderName,
|
||||||
|
allowFrom: effectiveAllowFrom,
|
||||||
|
});
|
||||||
|
const groupAllowedForCommands = isSenderAllowed({
|
||||||
|
senderId,
|
||||||
|
senderName,
|
||||||
|
allowFrom: effectiveGroupAllowFrom,
|
||||||
|
});
|
||||||
|
const commandAuthorized =
|
||||||
|
kind === "dm"
|
||||||
|
? dmPolicy === "open" || senderAllowedForCommands
|
||||||
|
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
|
useAccessGroups,
|
||||||
|
authorizers: [
|
||||||
|
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||||
|
{
|
||||||
|
configured: effectiveGroupAllowFrom.length > 0,
|
||||||
|
allowed: groupAllowedForCommands,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kind === "dm") {
|
||||||
|
if (dmPolicy === "disabled") {
|
||||||
|
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dmPolicy !== "open" && !senderAllowedForCommands) {
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
|
channel: "mattermost",
|
||||||
|
id: senderId,
|
||||||
|
meta: { name: senderName },
|
||||||
|
});
|
||||||
|
logVerboseMessage(
|
||||||
|
`mattermost: pairing request sender=${senderId} created=${created}`,
|
||||||
|
);
|
||||||
|
if (created) {
|
||||||
|
try {
|
||||||
|
await sendMessageMattermost(
|
||||||
|
`user:${senderId}`,
|
||||||
|
core.channel.pairing.buildPairingReply({
|
||||||
|
channel: "mattermost",
|
||||||
|
idLine: `Your Mattermost user id: ${senderId}`,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
{ accountId: account.accountId },
|
||||||
|
);
|
||||||
|
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`mattermost: pairing reply failed for ${senderId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logVerboseMessage(
|
||||||
|
`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (groupPolicy === "disabled") {
|
||||||
|
logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (groupPolicy === "allowlist") {
|
||||||
|
if (effectiveGroupAllowFrom.length === 0) {
|
||||||
|
logVerboseMessage("mattermost: drop group message (no group allowlist)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!groupAllowedForCommands) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind !== "dm" && isControlCommand && !commandAuthorized) {
|
||||||
|
logVerboseMessage(
|
||||||
|
`mattermost: drop control command from unauthorized sender ${senderId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
|
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
|
||||||
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
|
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
|
||||||
const channelDisplay =
|
const channelDisplay =
|
||||||
@@ -374,7 +523,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const historyKey = kind === "dm" ? null : sessionKey;
|
const historyKey = kind === "dm" ? null : sessionKey;
|
||||||
|
|
||||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
||||||
const rawText = post.message?.trim() || "";
|
|
||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
kind !== "dm" &&
|
kind !== "dm" &&
|
||||||
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
||||||
@@ -384,7 +532,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
(post.file_ids?.length
|
(post.file_ids?.length
|
||||||
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
|
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
|
||||||
: "");
|
: "");
|
||||||
const pendingSender = payload.data?.sender_name?.trim() || senderId;
|
const pendingSender = senderName;
|
||||||
const recordPendingHistory = () => {
|
const recordPendingHistory = () => {
|
||||||
if (!historyKey || historyLimit <= 0) return;
|
if (!historyKey || historyLimit <= 0) return;
|
||||||
const trimmed = pendingBody.trim();
|
const trimmed = pendingBody.trim();
|
||||||
@@ -402,11 +550,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
||||||
cfg,
|
|
||||||
surface: "mattermost",
|
|
||||||
});
|
|
||||||
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
|
|
||||||
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
|
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
|
||||||
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
||||||
const oncharResult = oncharEnabled
|
const oncharResult = oncharEnabled
|
||||||
@@ -414,8 +557,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
: { triggered: false, stripped: rawText };
|
: { triggered: false, stripped: rawText };
|
||||||
const oncharTriggered = oncharResult.triggered;
|
const oncharTriggered = oncharResult.triggered;
|
||||||
|
|
||||||
const shouldRequireMention = kind === "channel" && (account.requireMention ?? true);
|
const shouldRequireMention =
|
||||||
const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned;
|
kind !== "dm" &&
|
||||||
|
core.channel.groups.resolveRequireMention({
|
||||||
|
cfg,
|
||||||
|
channel: "mattermost",
|
||||||
|
accountId: account.accountId,
|
||||||
|
groupId: channelId,
|
||||||
|
}) !== false;
|
||||||
|
const shouldBypassMention =
|
||||||
|
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
|
||||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
||||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||||
|
|
||||||
@@ -424,17 +575,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "channel" && shouldRequireMention && canDetectMention) {
|
if (kind !== "dm" && shouldRequireMention && canDetectMention) {
|
||||||
if (!effectiveWasMentioned) {
|
if (!effectiveWasMentioned) {
|
||||||
recordPendingHistory();
|
recordPendingHistory();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderName =
|
|
||||||
payload.data?.sender_name?.trim() ||
|
|
||||||
(await resolveUserInfo(senderId))?.username?.trim() ||
|
|
||||||
senderId;
|
|
||||||
const mediaList = await resolveMattermostMedia(post.file_ids);
|
const mediaList = await resolveMattermostMedia(post.file_ids);
|
||||||
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
|
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
|
||||||
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
||||||
@@ -499,10 +645,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
|
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
|
||||||
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
||||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
||||||
useAccessGroups: cfg.commands?.useAccessGroups ?? false,
|
|
||||||
authorizers: [],
|
|
||||||
});
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
|
|||||||
@@ -67,4 +67,4 @@ export async function probeMattermost(
|
|||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,4 +205,4 @@ export async function sendMessageMattermost(
|
|||||||
messageId: post.id ?? "unknown",
|
messageId: post.id ?? "unknown",
|
||||||
channelId,
|
channelId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi
|
|||||||
}
|
}
|
||||||
if (trimmed.startsWith("@")) {
|
if (trimmed.startsWith("@")) {
|
||||||
const id = trimmed.slice(1).trim();
|
const id = trimmed.slice(1).trim();
|
||||||
return id ? `user:${id}` : undefined;
|
return id ? `@${id}` : undefined;
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("#")) {
|
if (trimmed.startsWith("#")) {
|
||||||
const id = trimmed.slice(1).trim();
|
const id = trimmed.slice(1).trim();
|
||||||
|
|||||||
@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk";
|
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||||
|
|
||||||
@@ -26,6 +26,14 @@ export type MattermostAccountConfig = {
|
|||||||
oncharPrefixes?: string[];
|
oncharPrefixes?: string[];
|
||||||
/** Require @mention to respond in channels. Default: true. */
|
/** Require @mention to respond in channels. Default: true. */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
|
/** Direct message policy (pairing/allowlist/open/disabled). */
|
||||||
|
dmPolicy?: DmPolicy;
|
||||||
|
/** Allowlist for direct messages (user ids or @usernames). */
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
/** Allowlist for group messages (user ids or @usernames). */
|
||||||
|
groupAllowFrom?: Array<string | number>;
|
||||||
|
/** Group message policy (allowlist/open/disabled). */
|
||||||
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function detectAutoKind(input: string): ChannelResolveKind {
|
|||||||
if (!trimmed) return "group";
|
if (!trimmed) return "group";
|
||||||
if (trimmed.startsWith("@")) return "user";
|
if (trimmed.startsWith("@")) return "user";
|
||||||
if (/^<@!?/.test(trimmed)) return "user";
|
if (/^<@!?/.test(trimmed)) return "user";
|
||||||
if (/^(user|discord|slack|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
|
if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
|
||||||
return "user";
|
return "user";
|
||||||
}
|
}
|
||||||
return "group";
|
return "group";
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
|||||||
"DISCORD_BOT_TOKEN",
|
"DISCORD_BOT_TOKEN",
|
||||||
"SLACK_BOT_TOKEN",
|
"SLACK_BOT_TOKEN",
|
||||||
"SLACK_APP_TOKEN",
|
"SLACK_APP_TOKEN",
|
||||||
"MATTERMOST_BOT_TOKEN",
|
|
||||||
"MATTERMOST_URL",
|
|
||||||
"CLAWDBOT_GATEWAY_TOKEN",
|
"CLAWDBOT_GATEWAY_TOKEN",
|
||||||
"CLAWDBOT_GATEWAY_PASSWORD",
|
"CLAWDBOT_GATEWAY_PASSWORD",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
|||||||
"telegram",
|
"telegram",
|
||||||
"discord",
|
"discord",
|
||||||
"slack",
|
"slack",
|
||||||
"mattermost",
|
|
||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
"msteams",
|
"msteams",
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
|||||||
path: ["slack"],
|
path: ["slack"],
|
||||||
message: "slack config moved to channels.slack (auto-migrated on load).",
|
message: "slack config moved to channels.slack (auto-migrated on load).",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: ["mattermost"],
|
|
||||||
message: "mattermost config moved to channels.mattermost (auto-migrated on load).",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: ["signal"],
|
path: ["signal"],
|
||||||
message: "signal config moved to channels.signal (auto-migrated on load).",
|
message: "signal config moved to channels.signal (auto-migrated on load).",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { DiscordConfig } from "./types.discord.js";
|
import type { DiscordConfig } from "./types.discord.js";
|
||||||
import type { IMessageConfig } from "./types.imessage.js";
|
import type { IMessageConfig } from "./types.imessage.js";
|
||||||
import type { MattermostConfig } from "./types.mattermost.js";
|
|
||||||
import type { MSTeamsConfig } from "./types.msteams.js";
|
import type { MSTeamsConfig } from "./types.msteams.js";
|
||||||
import type { SignalConfig } from "./types.signal.js";
|
import type { SignalConfig } from "./types.signal.js";
|
||||||
import type { SlackConfig } from "./types.slack.js";
|
import type { SlackConfig } from "./types.slack.js";
|
||||||
@@ -18,7 +17,6 @@ export type ChannelsConfig = {
|
|||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
slack?: SlackConfig;
|
slack?: SlackConfig;
|
||||||
mattermost?: MattermostConfig;
|
|
||||||
signal?: SignalConfig;
|
signal?: SignalConfig;
|
||||||
imessage?: IMessageConfig;
|
imessage?: IMessageConfig;
|
||||||
msteams?: MSTeamsConfig;
|
msteams?: MSTeamsConfig;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export type HookMappingConfig = {
|
|||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
| "slack"
|
| "slack"
|
||||||
| "mattermost"
|
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
| "msteams";
|
| "msteams";
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { BlockStreamingCoalesceConfig } from "./types.base.js";
|
|
||||||
|
|
||||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
|
||||||
|
|
||||||
export type MattermostAccountConfig = {
|
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
|
||||||
name?: string;
|
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
|
||||||
capabilities?: string[];
|
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
|
||||||
configWrites?: boolean;
|
|
||||||
/** If false, do not start this Mattermost account. Default: true. */
|
|
||||||
enabled?: boolean;
|
|
||||||
/** Bot token for Mattermost. */
|
|
||||||
botToken?: string;
|
|
||||||
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
|
|
||||||
baseUrl?: string;
|
|
||||||
/**
|
|
||||||
* Controls when channel messages trigger replies.
|
|
||||||
* - "oncall": only respond when mentioned
|
|
||||||
* - "onmessage": respond to every channel message
|
|
||||||
* - "onchar": respond when a trigger character prefixes the message
|
|
||||||
*/
|
|
||||||
chatmode?: MattermostChatMode;
|
|
||||||
/** Prefix characters that trigger onchar mode (default: [">", "!"]). */
|
|
||||||
oncharPrefixes?: string[];
|
|
||||||
/** Require @mention to respond in channels. Default: true. */
|
|
||||||
requireMention?: boolean;
|
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
|
||||||
textChunkLimit?: number;
|
|
||||||
/** Disable block streaming for this account. */
|
|
||||||
blockStreaming?: boolean;
|
|
||||||
/** Merge streamed block replies before sending. */
|
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MattermostConfig = {
|
|
||||||
/** Optional per-account Mattermost configuration (multi-account). */
|
|
||||||
accounts?: Record<string, MattermostAccountConfig>;
|
|
||||||
} & MattermostAccountConfig;
|
|
||||||
@@ -13,7 +13,6 @@ export type QueueModeByProvider = {
|
|||||||
telegram?: QueueMode;
|
telegram?: QueueMode;
|
||||||
discord?: QueueMode;
|
discord?: QueueMode;
|
||||||
slack?: QueueMode;
|
slack?: QueueMode;
|
||||||
mattermost?: QueueMode;
|
|
||||||
signal?: QueueMode;
|
signal?: QueueMode;
|
||||||
imessage?: QueueMode;
|
imessage?: QueueMode;
|
||||||
msteams?: QueueMode;
|
msteams?: QueueMode;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export * from "./types.hooks.js";
|
|||||||
export * from "./types.imessage.js";
|
export * from "./types.imessage.js";
|
||||||
export * from "./types.messages.js";
|
export * from "./types.messages.js";
|
||||||
export * from "./types.models.js";
|
export * from "./types.models.js";
|
||||||
export * from "./types.mattermost.js";
|
|
||||||
export * from "./types.msteams.js";
|
export * from "./types.msteams.js";
|
||||||
export * from "./types.plugins.js";
|
export * from "./types.plugins.js";
|
||||||
export * from "./types.queue.js";
|
export * from "./types.queue.js";
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export const HeartbeatSchema = z
|
|||||||
z.literal("telegram"),
|
z.literal("telegram"),
|
||||||
z.literal("discord"),
|
z.literal("discord"),
|
||||||
z.literal("slack"),
|
z.literal("slack"),
|
||||||
z.literal("mattermost"),
|
|
||||||
z.literal("msteams"),
|
z.literal("msteams"),
|
||||||
z.literal("signal"),
|
z.literal("signal"),
|
||||||
z.literal("imessage"),
|
z.literal("imessage"),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const HookMappingSchema = z
|
|||||||
z.literal("telegram"),
|
z.literal("telegram"),
|
||||||
z.literal("discord"),
|
z.literal("discord"),
|
||||||
z.literal("slack"),
|
z.literal("slack"),
|
||||||
z.literal("mattermost"),
|
|
||||||
z.literal("signal"),
|
z.literal("signal"),
|
||||||
z.literal("imessage"),
|
z.literal("imessage"),
|
||||||
z.literal("msteams"),
|
z.literal("msteams"),
|
||||||
|
|||||||
@@ -367,27 +367,6 @@ export const SlackConfigSchema = SlackAccountSchema.extend({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MattermostAccountSchema = z
|
|
||||||
.object({
|
|
||||||
name: z.string().optional(),
|
|
||||||
capabilities: z.array(z.string()).optional(),
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
configWrites: z.boolean().optional(),
|
|
||||||
botToken: z.string().optional(),
|
|
||||||
baseUrl: z.string().optional(),
|
|
||||||
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
|
||||||
oncharPrefixes: z.array(z.string()).optional(),
|
|
||||||
requireMention: z.boolean().optional(),
|
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
|
||||||
blockStreaming: z.boolean().optional(),
|
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const MattermostConfigSchema = MattermostAccountSchema.extend({
|
|
||||||
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SignalAccountSchemaBase = z
|
export const SignalAccountSchemaBase = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
BlueBubblesConfigSchema,
|
BlueBubblesConfigSchema,
|
||||||
DiscordConfigSchema,
|
DiscordConfigSchema,
|
||||||
IMessageConfigSchema,
|
IMessageConfigSchema,
|
||||||
MattermostConfigSchema,
|
|
||||||
MSTeamsConfigSchema,
|
MSTeamsConfigSchema,
|
||||||
SignalConfigSchema,
|
SignalConfigSchema,
|
||||||
SlackConfigSchema,
|
SlackConfigSchema,
|
||||||
@@ -28,7 +27,6 @@ export const ChannelsSchema = z
|
|||||||
telegram: TelegramConfigSchema.optional(),
|
telegram: TelegramConfigSchema.optional(),
|
||||||
discord: DiscordConfigSchema.optional(),
|
discord: DiscordConfigSchema.optional(),
|
||||||
slack: SlackConfigSchema.optional(),
|
slack: SlackConfigSchema.optional(),
|
||||||
mattermost: MattermostConfigSchema.optional(),
|
|
||||||
signal: SignalConfigSchema.optional(),
|
signal: SignalConfigSchema.optional(),
|
||||||
imessage: IMessageConfigSchema.optional(),
|
imessage: IMessageConfigSchema.optional(),
|
||||||
bluebubbles: BlueBubblesConfigSchema.optional(),
|
bluebubbles: BlueBubblesConfigSchema.optional(),
|
||||||
|
|||||||
@@ -28,18 +28,11 @@ type SendMatrixMessage = (
|
|||||||
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
|
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
|
||||||
) => Promise<{ messageId: string; roomId: string }>;
|
) => Promise<{ messageId: string; roomId: string }>;
|
||||||
|
|
||||||
type SendMattermostMessage = (
|
|
||||||
to: string,
|
|
||||||
text: string,
|
|
||||||
opts?: { accountId?: string; mediaUrl?: string; replyToId?: string },
|
|
||||||
) => Promise<{ messageId: string; channelId: string }>;
|
|
||||||
|
|
||||||
export type OutboundSendDeps = {
|
export type OutboundSendDeps = {
|
||||||
sendWhatsApp?: typeof sendMessageWhatsApp;
|
sendWhatsApp?: typeof sendMessageWhatsApp;
|
||||||
sendTelegram?: typeof sendMessageTelegram;
|
sendTelegram?: typeof sendMessageTelegram;
|
||||||
sendDiscord?: typeof sendMessageDiscord;
|
sendDiscord?: typeof sendMessageDiscord;
|
||||||
sendSlack?: typeof sendMessageSlack;
|
sendSlack?: typeof sendMessageSlack;
|
||||||
sendMattermost?: SendMattermostMessage;
|
|
||||||
sendSignal?: typeof sendMessageSignal;
|
sendSignal?: typeof sendMessageSignal;
|
||||||
sendIMessage?: typeof sendMessageIMessage;
|
sendIMessage?: typeof sendMessageIMessage;
|
||||||
sendMatrix?: SendMatrixMessage;
|
sendMatrix?: SendMatrixMessage;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const MARKDOWN_CAPABLE_CHANNELS = new Set<string>([
|
|||||||
"telegram",
|
"telegram",
|
||||||
"signal",
|
"signal",
|
||||||
"discord",
|
"discord",
|
||||||
"mattermost",
|
|
||||||
"tui",
|
"tui",
|
||||||
INTERNAL_MESSAGE_CHANNEL,
|
INTERNAL_MESSAGE_CHANNEL,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -164,39 +164,6 @@ export type SlackStatus = {
|
|||||||
lastProbeAt?: number | null;
|
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 = {
|
export type SignalProbe = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status?: number | null;
|
status?: number | null;
|
||||||
@@ -415,7 +382,6 @@ export type CronPayload =
|
|||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
| "slack"
|
| "slack"
|
||||||
| "mattermost"
|
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
| "msteams";
|
| "msteams";
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import type {
|
|||||||
ChannelsStatusSnapshot,
|
ChannelsStatusSnapshot,
|
||||||
DiscordStatus,
|
DiscordStatus,
|
||||||
IMessageStatus,
|
IMessageStatus,
|
||||||
MattermostStatus,
|
|
||||||
NostrProfile,
|
NostrProfile,
|
||||||
NostrStatus,
|
NostrStatus,
|
||||||
SignalStatus,
|
SignalStatus,
|
||||||
@@ -24,7 +23,6 @@ import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
|
|||||||
import { renderChannelConfigSection } from "./channels.config";
|
import { renderChannelConfigSection } from "./channels.config";
|
||||||
import { renderDiscordCard } from "./channels.discord";
|
import { renderDiscordCard } from "./channels.discord";
|
||||||
import { renderIMessageCard } from "./channels.imessage";
|
import { renderIMessageCard } from "./channels.imessage";
|
||||||
import { renderMattermostCard } from "./channels.mattermost";
|
|
||||||
import { renderNostrCard } from "./channels.nostr";
|
import { renderNostrCard } from "./channels.nostr";
|
||||||
import { renderSignalCard } from "./channels.signal";
|
import { renderSignalCard } from "./channels.signal";
|
||||||
import { renderSlackCard } from "./channels.slack";
|
import { renderSlackCard } from "./channels.slack";
|
||||||
@@ -41,7 +39,6 @@ export function renderChannels(props: ChannelsProps) {
|
|||||||
| undefined;
|
| undefined;
|
||||||
const discord = (channels?.discord ?? null) as DiscordStatus | null;
|
const discord = (channels?.discord ?? null) as DiscordStatus | null;
|
||||||
const slack = (channels?.slack ?? null) as SlackStatus | 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 signal = (channels?.signal ?? null) as SignalStatus | null;
|
||||||
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
|
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
|
||||||
const nostr = (channels?.nostr ?? null) as NostrStatus | null;
|
const nostr = (channels?.nostr ?? null) as NostrStatus | null;
|
||||||
@@ -65,7 +62,6 @@ export function renderChannels(props: ChannelsProps) {
|
|||||||
telegram,
|
telegram,
|
||||||
discord,
|
discord,
|
||||||
slack,
|
slack,
|
||||||
mattermost,
|
|
||||||
signal,
|
signal,
|
||||||
imessage,
|
imessage,
|
||||||
nostr,
|
nostr,
|
||||||
@@ -139,12 +135,6 @@ function renderChannel(
|
|||||||
slack: data.slack,
|
slack: data.slack,
|
||||||
accountCountLabel,
|
accountCountLabel,
|
||||||
});
|
});
|
||||||
case "mattermost":
|
|
||||||
return renderMattermostCard({
|
|
||||||
props,
|
|
||||||
mattermost: data.mattermost,
|
|
||||||
accountCountLabel,
|
|
||||||
});
|
|
||||||
case "signal":
|
case "signal":
|
||||||
return renderSignalCard({
|
return renderSignalCard({
|
||||||
props,
|
props,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
ConfigUiHints,
|
ConfigUiHints,
|
||||||
DiscordStatus,
|
DiscordStatus,
|
||||||
IMessageStatus,
|
IMessageStatus,
|
||||||
MattermostStatus,
|
|
||||||
NostrProfile,
|
NostrProfile,
|
||||||
NostrStatus,
|
NostrStatus,
|
||||||
SignalStatus,
|
SignalStatus,
|
||||||
@@ -54,7 +53,6 @@ export type ChannelsChannelData = {
|
|||||||
telegram?: TelegramStatus;
|
telegram?: TelegramStatus;
|
||||||
discord?: DiscordStatus | null;
|
discord?: DiscordStatus | null;
|
||||||
slack?: SlackStatus | null;
|
slack?: SlackStatus | null;
|
||||||
mattermost?: MattermostStatus | null;
|
|
||||||
signal?: SignalStatus | null;
|
signal?: SignalStatus | null;
|
||||||
imessage?: IMessageStatus | null;
|
imessage?: IMessageStatus | null;
|
||||||
nostr?: NostrStatus | null;
|
nostr?: NostrStatus | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user