Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72a3674a7 | ||
|
|
8bf1af35de |
No files matched your search
+1
-8
@@ -5,7 +5,6 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.22 (unreleased)
|
## 2026.1.22 (unreleased)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Mattermost plugin channel support with pairing + allowlist gating. (#1428) Thanks @damoahdominic.
|
|
||||||
- Highlight: OpenProse plugin skill pack with `/prose` slash command, plugin-shipped skills, and docs. https://docs.clawd.bot/prose
|
- Highlight: OpenProse plugin skill pack with `/prose` slash command, plugin-shipped skills, and docs. https://docs.clawd.bot/prose
|
||||||
- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07.
|
- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07.
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
@@ -26,19 +25,13 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
- CLI: suppress progress spinner noise for `logs --follow` and JSON output. (#1472) Thanks @czekaj.
|
||||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||||
- 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.
|
||||||
<<<<<<< Updated upstream
|
|
||||||
- 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.
|
|
||||||
||||||| Stash base
|
|
||||||
=======
|
|
||||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
|
||||||
>>>>>>> Stashed changes
|
|
||||||
- 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,6 +40,17 @@ 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 }
|
||||||
@@ -85,6 +96,15 @@ 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…" }
|
||||||
@@ -193,6 +213,38 @@ 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 }
|
||||||
@@ -244,7 +296,7 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ChannelItem] {
|
var orderedChannels: [ChannelItem] {
|
||||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
let fallback = ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage"]
|
||||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||||
let channels = order.enumerated().map { index, id in
|
let channels = order.enumerated().map { index, id in
|
||||||
ChannelItem(
|
ChannelItem(
|
||||||
@@ -307,6 +359,8 @@ 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":
|
||||||
@@ -326,6 +380,8 @@ 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":
|
||||||
@@ -345,6 +401,8 @@ 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":
|
||||||
@@ -377,6 +435,10 @@ 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)
|
||||||
@@ -411,6 +473,10 @@ 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,6 +85,40 @@ 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,6 +12,7 @@ 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,10 +12,11 @@ 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", "signal", "imessage"],
|
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
|
||||||
channelLabels: [
|
channelLabels: [
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
|
"mattermost": "Mattermost",
|
||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
@@ -57,6 +58,21 @@ 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",
|
||||||
@@ -82,6 +98,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
channelDefaultAccountId: [
|
channelDefaultAccountId: [
|
||||||
"whatsapp": "default",
|
"whatsapp": "default",
|
||||||
"telegram": "default",
|
"telegram": "default",
|
||||||
|
"mattermost": "default",
|
||||||
"signal": "default",
|
"signal": "default",
|
||||||
"imessage": "default",
|
"imessage": "default",
|
||||||
])
|
])
|
||||||
@@ -98,10 +115,11 @@ 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", "signal", "imessage"],
|
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
|
||||||
channelLabels: [
|
channelLabels: [
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
|
"mattermost": "Mattermost",
|
||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
@@ -128,6 +146,19 @@ 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",
|
||||||
@@ -154,6 +185,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
channelDefaultAccountId: [
|
channelDefaultAccountId: [
|
||||||
"whatsapp": "default",
|
"whatsapp": "default",
|
||||||
"telegram": "default",
|
"telegram": "default",
|
||||||
|
"mattermost": "default",
|
||||||
"signal": "default",
|
"signal": "default",
|
||||||
"imessage": "default",
|
"imessage": "default",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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` (plugin) / `signal` / `imessage` / `last`
|
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` / `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 (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
- Slack/Discord/Mattermost 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` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost`, `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 (plugin), 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, 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 (plugin, installed separately).
|
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs.
|
||||||
- [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,35 +5,12 @@ read_when:
|
|||||||
- Debugging Mattermost routing
|
- Debugging Mattermost routing
|
||||||
---
|
---
|
||||||
|
|
||||||
# Mattermost (plugin)
|
# Mattermost
|
||||||
|
|
||||||
Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported.
|
|
||||||
Mattermost is a self-hostable team messaging platform; see the official site at
|
|
||||||
[mattermost.com](https://mattermost.com) for product details and downloads.
|
|
||||||
|
|
||||||
## 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) Install the Mattermost plugin.
|
1) Create a Mattermost bot account and copy the **bot token**.
|
||||||
2) Create a Mattermost bot account and copy the **bot token**.
|
2) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
|
||||||
3) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
|
3) Configure Clawdbot and start the gateway.
|
||||||
4) Configure Clawdbot and start the gateway.
|
|
||||||
|
|
||||||
Minimal config:
|
Minimal config:
|
||||||
```json5
|
```json5
|
||||||
@@ -42,8 +19,7 @@ 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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,18 +56,6 @@ 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 (plugin)/Signal/iMessage)
|
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage)
|
||||||
- You want to check channel status or tail channel logs
|
- You want to check channel status or tail channel logs
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -352,7 +352,7 @@ Options:
|
|||||||
## Channel helpers
|
## Channel helpers
|
||||||
|
|
||||||
### `channels`
|
### `channels`
|
||||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/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).
|
||||||
|
|||||||
+4
-4
@@ -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 (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
(Discord/Slack/Mattermost/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` (Mattermost requires plugin)
|
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
|
||||||
|
|
||||||
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 (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
- Mattermost: `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 (plugin)/Signal/iMessage/MS Teams
|
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost/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)
|
||||||
|
|||||||
@@ -109,14 +109,6 @@
|
|||||||
"source": "/opencode/",
|
"source": "/opencode/",
|
||||||
"destination": "/providers/opencode"
|
"destination": "/providers/opencode"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"source": "/mattermost",
|
|
||||||
"destination": "/channels/mattermost"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/mattermost/",
|
|
||||||
"destination": "/channels/mattermost"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"source": "/glm",
|
"source": "/glm",
|
||||||
"destination": "/providers/glm"
|
"destination": "/providers/glm"
|
||||||
|
|||||||
@@ -1206,9 +1206,6 @@ 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
|
||||||
@@ -1218,7 +1215,6 @@ 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
|
||||||
@@ -1234,11 +1230,6 @@ 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.
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -12,8 +12,7 @@ read_when:
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
|
<strong>Any OS + WhatsApp/Telegram/Discord/Mattermost/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>
|
||||||
|
|
||||||
@@ -24,7 +23,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), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono). Plugins add Mattermost (Bot API + WebSocket) and more.
|
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 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
|
||||||
@@ -45,7 +44,7 @@ Remote access: [Web surfaces](/web) and [Tailscale](/gateway/tailscale)
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
WhatsApp / Telegram / Discord / iMessage (+ plugins)
|
WhatsApp / Telegram / Discord / Mattermost
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────┐
|
┌───────────────────────────┐
|
||||||
@@ -80,7 +79,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 (plugin)** — Bot token + WebSocket events
|
- 🧩 **Mattermost Bot** — 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))
|
||||||
@@ -192,7 +191,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 (plugin)](/channels/mattermost)
|
- [Mattermost](/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)
|
||||||
|
|||||||
+7
-3
@@ -25,9 +25,13 @@ Bundled plugins are disabled by default. Enable OpenProse:
|
|||||||
clawdbot plugins enable open-prose
|
clawdbot plugins enable open-prose
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the Gateway after enabling the plugin.
|
If you're using a local checkout instead of bundled:
|
||||||
|
|
||||||
Dev/local checkout: `clawdbot plugins install ./extensions/open-prose`
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/open-prose
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Gateway after enabling or installing the plugin.
|
||||||
|
|
||||||
Related docs: [Plugins](/plugin), [Plugin manifest](/plugins/manifest), [Skills](/tools/skills).
|
Related docs: [Plugins](/plugin), [Plugin manifest](/plugins/manifest), [Skills](/tools/skills).
|
||||||
|
|
||||||
@@ -128,4 +132,4 @@ If your tool allowlist blocks these tools, OpenProse programs will fail. See [Sk
|
|||||||
|
|
||||||
Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects.
|
Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects.
|
||||||
|
|
||||||
For deterministic, approval-gated workflows, compare with [Lobster](/lobster).
|
For deterministic, approval-gated workflows, compare with [Lobster](/tools/lobster).
|
||||||
@@ -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 (plugin)/etc.)? See [Channels](/channels).
|
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost/etc.)? See [Channels](/channels).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ This document explains how Clawdbot manages sessions end-to-end:
|
|||||||
- **Session routing** (how inbound messages map to a `sessionKey`)
|
- **Session routing** (how inbound messages map to a `sessionKey`)
|
||||||
- **Session store** (`sessions.json`) and what it tracks
|
- **Session store** (`sessions.json`) and what it tracks
|
||||||
- **Transcript persistence** (`*.jsonl`) and its structure
|
- **Transcript persistence** (`*.jsonl`) and its structure
|
||||||
- **Transcript hygiene** (provider-specific fixups before runs)
|
|
||||||
- **Context limits** (context window vs tracked tokens)
|
- **Context limits** (context window vs tracked tokens)
|
||||||
- **Compaction** (manual + auto-compaction) and where to hook pre-compaction work
|
- **Compaction** (manual + auto-compaction) and where to hook pre-compaction work
|
||||||
- **Silent housekeeping** (e.g. memory writes that shouldn’t produce user-visible output)
|
- **Silent housekeeping** (e.g. memory writes that shouldn’t produce user-visible output)
|
||||||
@@ -21,7 +20,6 @@ If you want a higher-level overview first, start with:
|
|||||||
- [/concepts/session](/concepts/session)
|
- [/concepts/session](/concepts/session)
|
||||||
- [/concepts/compaction](/concepts/compaction)
|
- [/concepts/compaction](/concepts/compaction)
|
||||||
- [/concepts/session-pruning](/concepts/session-pruning)
|
- [/concepts/session-pruning](/concepts/session-pruning)
|
||||||
- [/reference/transcript-hygiene](/reference/transcript-hygiene)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Reference: provider-specific transcript sanitization and repair rules"
|
|
||||||
read_when:
|
|
||||||
- You are debugging provider request rejections tied to transcript shape
|
|
||||||
- You are changing transcript sanitization or tool-call repair logic
|
|
||||||
- You are investigating tool-call id mismatches across providers
|
|
||||||
---
|
|
||||||
# Transcript Hygiene (Provider Fixups)
|
|
||||||
|
|
||||||
This document describes **provider-specific fixes** applied to transcripts before a run
|
|
||||||
(building model context). These are **in-memory** adjustments used to satisfy strict
|
|
||||||
provider requirements. They do **not** rewrite the stored JSONL transcript on disk.
|
|
||||||
|
|
||||||
Scope includes:
|
|
||||||
- Tool call id sanitization
|
|
||||||
- Tool result pairing repair
|
|
||||||
- Turn validation / ordering
|
|
||||||
- Thought signature cleanup
|
|
||||||
- Image payload sanitization
|
|
||||||
|
|
||||||
If you need transcript storage details, see:
|
|
||||||
- [/reference/session-management-compaction](/reference/session-management-compaction)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Where this runs
|
|
||||||
|
|
||||||
All transcript hygiene is centralized in the embedded runner:
|
|
||||||
- Policy selection: `src/agents/transcript-policy.ts`
|
|
||||||
- Sanitization/repair application: `sanitizeSessionHistory` in `src/agents/pi-embedded-runner/google.ts`
|
|
||||||
|
|
||||||
The policy uses `provider`, `modelApi`, and `modelId` to decide what to apply.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Global rule: image sanitization
|
|
||||||
|
|
||||||
Image payloads are always sanitized to prevent provider-side rejection due to size
|
|
||||||
limits (downscale/recompress oversized base64 images).
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
- `sanitizeSessionMessagesImages` in `src/agents/pi-embedded-helpers/images.ts`
|
|
||||||
- `sanitizeContentBlocksImages` in `src/agents/tool-images.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provider matrix (current behavior)
|
|
||||||
|
|
||||||
**OpenAI / OpenAI Codex**
|
|
||||||
- Image sanitization only.
|
|
||||||
- No tool call id sanitization.
|
|
||||||
- No tool result pairing repair.
|
|
||||||
- No turn validation or reordering.
|
|
||||||
- No synthetic tool results.
|
|
||||||
- No thought signature stripping.
|
|
||||||
|
|
||||||
**Google (Generative AI / Gemini CLI / Antigravity)**
|
|
||||||
- Tool call id sanitization: strict alphanumeric.
|
|
||||||
- Tool result pairing repair and synthetic tool results.
|
|
||||||
- Turn validation (Gemini-style turn alternation).
|
|
||||||
- Google turn ordering fixup (prepend a tiny user bootstrap if history starts with assistant).
|
|
||||||
- Antigravity Claude: normalize thinking signatures; drop unsigned thinking blocks.
|
|
||||||
|
|
||||||
**Anthropic / Minimax (Anthropic-compatible)**
|
|
||||||
- Tool result pairing repair and synthetic tool results.
|
|
||||||
- Turn validation (merge consecutive user turns to satisfy strict alternation).
|
|
||||||
|
|
||||||
**Mistral (including model-id based detection)**
|
|
||||||
- Tool call id sanitization: strict9 (alphanumeric length 9).
|
|
||||||
|
|
||||||
**OpenRouter Gemini**
|
|
||||||
- Thought signature cleanup: strip non-base64 `thought_signature` values (keep base64).
|
|
||||||
|
|
||||||
**Everything else**
|
|
||||||
- Image sanitization only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Historical behavior (pre-2026.1.22)
|
|
||||||
|
|
||||||
Before the 2026.1.22 release, Clawdbot applied multiple layers of transcript hygiene:
|
|
||||||
|
|
||||||
- A **transcript-sanitize extension** ran on every context build and could:
|
|
||||||
- Repair tool use/result pairing.
|
|
||||||
- Sanitize tool call ids (including a non-strict mode that preserved `_`/`-`).
|
|
||||||
- The runner also performed provider-specific sanitization, which duplicated work.
|
|
||||||
- Additional mutations occurred outside the provider policy, including:
|
|
||||||
- Stripping `<final>` tags from assistant text before persistence.
|
|
||||||
- Dropping empty assistant error turns.
|
|
||||||
- Trimming assistant content after tool calls.
|
|
||||||
|
|
||||||
This complexity caused cross-provider regressions (notably `openai-responses`
|
|
||||||
`call_id|fc_id` pairing). The 2026.1.22 cleanup removed the extension, centralized
|
|
||||||
logic in the runner, and made OpenAI **no-touch** beyond image sanitization.
|
|
||||||
+2
-2
@@ -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 + 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.
|
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.
|
||||||
|
|
||||||
## ⚠️ 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 (plugin)
|
- send messages back out via WhatsApp/Telegram/Discord/Mattermost
|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
+3
-3
@@ -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 (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.
|
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.
|
||||||
|
|
||||||
## 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 (plugin), Signal, iMessage)
|
- **Providers** (WhatsApp, Telegram, Discord, Mattermost, 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 (plugin)/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/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 (plugin)/...)
|
- channels (WhatsApp/Telegram/Discord/Mattermost/...)
|
||||||
- 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 bot tokens, Mattermost plugin tokens, etc.
|
- **Providers**: WhatsApp QR login, Telegram/Discord/Mattermost bot 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 (plugin): [Mattermost](/channels/mattermost)
|
- Mattermost: [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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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) (plugin)
|
- [Mattermost](/channels/mattermost)
|
||||||
- [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 (plugin), Signal)
|
- Providers (Telegram, WhatsApp, Discord, Mattermost, 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 (plugin): bot token + base URL.
|
- Mattermost: 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.
|
||||||
|
|||||||
+1
-1
@@ -155,7 +155,7 @@ tool usage guidance is injected into prompts. Some plugins ship their own skills
|
|||||||
alongside tools (for example, the voice-call plugin).
|
alongside tools (for example, the voice-call plugin).
|
||||||
|
|
||||||
Optional plugin tools:
|
Optional plugin tools:
|
||||||
- [Lobster](/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
||||||
|
|
||||||
## Tool inventory
|
## Tool inventory
|
||||||
|
|
||||||
|
|||||||
@@ -151,10 +151,6 @@ If `requiresApproval` is present, inspect the prompt and decide:
|
|||||||
- `approve: true` → resume and continue side effects
|
- `approve: true` → resume and continue side effects
|
||||||
- `approve: false` → cancel and finalize the workflow
|
- `approve: false` → cancel and finalize the workflow
|
||||||
|
|
||||||
## OpenProse
|
|
||||||
|
|
||||||
OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, then run a Lobster pipeline for deterministic approvals. If a Prose program needs Lobster, allow the `lobster` tool for sub-agents via `tools.subagents.tools`. See [OpenProse](/prose).
|
|
||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
- **Local subprocess only** — no network calls from the plugin itself.
|
- **Local subprocess only** — no network calls from the plugin itself.
|
||||||
|
|||||||
@@ -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 + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost 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.*`)
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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,7 +3,6 @@ import {
|
|||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatPairingApproveHint,
|
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
@@ -39,40 +38,14 @@ 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,
|
||||||
@@ -111,39 +84,6 @@ 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,
|
||||||
@@ -165,21 +105,23 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: new Error(
|
error: new Error(
|
||||||
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
|
"Delivering to Mattermost requires --to <channelId|user:ID|channel:ID>",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||||
const result = await sendMessageMattermost(to, text, {
|
const send = deps?.sendMattermost ?? sendMessageMattermost;
|
||||||
|
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, replyToId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||||
const result = await sendMessageMattermost(to, text, {
|
const send = deps?.sendMattermost ?? sendMessageMattermost;
|
||||||
|
const result = await send(to, text, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk";
|
||||||
BlockStreamingCoalesceSchema,
|
|
||||||
DmPolicySchema,
|
|
||||||
GroupPolicySchema,
|
|
||||||
requireOpenAllowFrom,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
const MattermostAccountSchemaBase = z
|
const MattermostAccountSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
@@ -18,36 +13,12 @@ const MattermostAccountSchemaBase = 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();
|
||||||
|
|
||||||
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
|
export const MattermostConfigSchema = MattermostAccountSchema.extend({
|
||||||
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(),
|
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,39 +141,6 @@ 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;
|
||||||
@@ -379,122 +346,6 @@ 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 =
|
||||||
@@ -523,6 +374,7 @@ 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) ||
|
||||||
@@ -532,7 +384,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 = senderName;
|
const pendingSender = payload.data?.sender_name?.trim() || senderId;
|
||||||
const recordPendingHistory = () => {
|
const recordPendingHistory = () => {
|
||||||
if (!historyKey || historyLimit <= 0) return;
|
if (!historyKey || historyLimit <= 0) return;
|
||||||
const trimmed = pendingBody.trim();
|
const trimmed = pendingBody.trim();
|
||||||
@@ -550,6 +402,11 @@ 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
|
||||||
@@ -557,16 +414,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
: { triggered: false, stripped: rawText };
|
: { triggered: false, stripped: rawText };
|
||||||
const oncharTriggered = oncharResult.triggered;
|
const oncharTriggered = oncharResult.triggered;
|
||||||
|
|
||||||
const shouldRequireMention =
|
const shouldRequireMention = kind === "channel" && (account.requireMention ?? true);
|
||||||
kind !== "dm" &&
|
const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned;
|
||||||
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;
|
||||||
|
|
||||||
@@ -575,12 +424,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind !== "dm" && shouldRequireMention && canDetectMention) {
|
if (kind === "channel" && 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;
|
||||||
@@ -645,6 +499,10 @@ 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 ? `@${id}` : undefined;
|
return id ? `user:${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, DmPolicy, GroupPolicy } from "clawdbot/plugin-sdk";
|
import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||||
|
|
||||||
@@ -26,14 +26,6 @@ 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. */
|
||||||
|
|||||||
+37
@@ -86,6 +86,43 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
expect(toolResult.role).toBe("toolResult");
|
expect(toolResult.role).toBe("toolResult");
|
||||||
expect(toolResult.toolCallId).toBe("call123fc456");
|
expect(toolResult.toolCallId).toBe("call123fc456");
|
||||||
});
|
});
|
||||||
|
it("drops assistant blocks after a tool call when enforceToolCallLast is enabled", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "before" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
{ type: "thinking", thinking: "after", thinkingSignature: "sig" },
|
||||||
|
{ type: "text", text: "after text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||||
|
enforceToolCallLast: true,
|
||||||
|
});
|
||||||
|
const assistant = out[0] as { content?: Array<{ type?: string }> };
|
||||||
|
expect(assistant.content?.map((b) => b.type)).toEqual(["text", "toolCall"]);
|
||||||
|
});
|
||||||
|
it("keeps assistant blocks after a tool call when enforceToolCallLast is disabled", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "before" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
{ type: "thinking", thinking: "after", thinkingSignature: "sig" },
|
||||||
|
{ type: "text", text: "after text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
const assistant = out[0] as { content?: Array<{ type?: string }> };
|
||||||
|
expect(assistant.content?.map((b) => b.type)).toEqual(["text", "toolCall", "thinking", "text"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not synthesize tool call input when missing", async () => {
|
it("does not synthesize tool call input when missing", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-4
@@ -87,7 +87,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
expect(out).toHaveLength(1);
|
expect(out).toHaveLength(1);
|
||||||
expect(out[0]?.role).toBe("user");
|
expect(out[0]?.role).toBe("user");
|
||||||
});
|
});
|
||||||
it("keeps empty assistant error messages", async () => {
|
it("drops empty assistant error messages", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{ role: "user", content: "hello" },
|
{ role: "user", content: "hello" },
|
||||||
{ role: "assistant", stopReason: "error", content: [] },
|
{ role: "assistant", stopReason: "error", content: [] },
|
||||||
@@ -96,10 +96,8 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
|
|
||||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
expect(out).toHaveLength(3);
|
expect(out).toHaveLength(1);
|
||||||
expect(out[0]?.role).toBe("user");
|
expect(out[0]?.role).toBe("user");
|
||||||
expect(out[1]?.role).toBe("assistant");
|
|
||||||
expect(out[2]?.role).toBe("assistant");
|
|
||||||
});
|
});
|
||||||
it("leaves non-assistant messages unchanged", async () => {
|
it("leaves non-assistant messages unchanged", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export function isEmptyAssistantMessageContent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmptyAssistantErrorMessage(
|
||||||
|
message: Extract<AgentMessage, { role: "assistant" }>,
|
||||||
|
): boolean {
|
||||||
|
if (message.stopReason !== "error") return false;
|
||||||
|
return isEmptyAssistantMessageContent(message);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sanitizeSessionMessagesImages(
|
export async function sanitizeSessionMessagesImages(
|
||||||
messages: AgentMessage[],
|
messages: AgentMessage[],
|
||||||
label: string,
|
label: string,
|
||||||
@@ -33,6 +40,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
* - "strict9" (alphanumeric only, length 9)
|
* - "strict9" (alphanumeric only, length 9)
|
||||||
*/
|
*/
|
||||||
toolCallIdMode?: ToolCallIdMode;
|
toolCallIdMode?: ToolCallIdMode;
|
||||||
|
enforceToolCallLast?: boolean;
|
||||||
preserveSignatures?: boolean;
|
preserveSignatures?: boolean;
|
||||||
sanitizeThoughtSignatures?: {
|
sanitizeThoughtSignatures?: {
|
||||||
allowBase64Only?: boolean;
|
allowBase64Only?: boolean;
|
||||||
@@ -82,17 +90,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
|
|
||||||
if (role === "assistant") {
|
if (role === "assistant") {
|
||||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
if (assistantMsg.stopReason === "error") {
|
if (allowNonImageSanitization && isEmptyAssistantErrorMessage(assistantMsg)) {
|
||||||
const content = assistantMsg.content;
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
const nextContent = (await sanitizeContentBlocksImages(
|
|
||||||
content as unknown as ContentBlock[],
|
|
||||||
label,
|
|
||||||
)) as unknown as typeof assistantMsg.content;
|
|
||||||
out.push({ ...assistantMsg, content: nextContent });
|
|
||||||
} else {
|
|
||||||
out.push(assistantMsg);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = assistantMsg.content;
|
const content = assistantMsg.content;
|
||||||
@@ -115,8 +113,25 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
||||||
return rec.text.trim().length > 0;
|
return rec.text.trim().length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizedContent = options?.enforceToolCallLast
|
||||||
|
? (() => {
|
||||||
|
let lastToolIndex = -1;
|
||||||
|
for (let i = filteredContent.length - 1; i >= 0; i -= 1) {
|
||||||
|
const block = filteredContent[i];
|
||||||
|
if (!block || typeof block !== "object") continue;
|
||||||
|
const type = (block as { type?: unknown }).type;
|
||||||
|
if (type === "functionCall" || type === "toolUse" || type === "toolCall") {
|
||||||
|
lastToolIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastToolIndex === -1) return filteredContent;
|
||||||
|
return filteredContent.slice(0, lastToolIndex + 1);
|
||||||
|
})()
|
||||||
|
: filteredContent;
|
||||||
const finalContent = (await sanitizeContentBlocksImages(
|
const finalContent = (await sanitizeContentBlocksImages(
|
||||||
filteredContent as unknown as ContentBlock[],
|
normalizedContent as unknown as ContentBlock[],
|
||||||
label,
|
label,
|
||||||
)) as unknown as typeof assistantMsg.content;
|
)) as unknown as typeof assistantMsg.content;
|
||||||
if (finalContent.length === 0) {
|
if (finalContent.length === 0) {
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
agentId: sessionAgentId,
|
agentId: sessionAgentId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||||
|
stripFinalTags: transcriptPolicy.stripFinalTags,
|
||||||
});
|
});
|
||||||
trackSessionManagerAccess(params.sessionFile);
|
trackSessionManagerAccess(params.sessionFile);
|
||||||
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function buildEmbeddedExtensionPaths(params: {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
model: Model<Api> | undefined;
|
model: Model<Api> | undefined;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const paths: string[] = [];
|
const paths = [resolvePiExtensionPath("transcript-sanitize")];
|
||||||
if (resolveCompactionMode(params.cfg) === "safeguard") {
|
if (resolveCompactionMode(params.cfg) === "safeguard") {
|
||||||
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
policy?: TranscriptPolicy;
|
policy?: TranscriptPolicy;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
// Keep docs/reference/transcript-hygiene.md in sync with any logic changes here.
|
|
||||||
const policy =
|
const policy =
|
||||||
params.policy ??
|
params.policy ??
|
||||||
resolveTranscriptPolicy({
|
resolveTranscriptPolicy({
|
||||||
@@ -262,6 +261,7 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sanitizeMode: policy.sanitizeMode,
|
sanitizeMode: policy.sanitizeMode,
|
||||||
sanitizeToolCallIds: policy.sanitizeToolCallIds,
|
sanitizeToolCallIds: policy.sanitizeToolCallIds,
|
||||||
toolCallIdMode: policy.toolCallIdMode,
|
toolCallIdMode: policy.toolCallIdMode,
|
||||||
|
enforceToolCallLast: policy.enforceToolCallLast,
|
||||||
preserveSignatures: policy.preserveSignatures,
|
preserveSignatures: policy.preserveSignatures,
|
||||||
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
|
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ export async function runEmbeddedAttempt(
|
|||||||
agentId: sessionAgentId,
|
agentId: sessionAgentId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||||
|
stripFinalTags: transcriptPolicy.stripFinalTags,
|
||||||
});
|
});
|
||||||
trackSessionManagerAccess(params.sessionFile);
|
trackSessionManagerAccess(params.sessionFile);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Transcript repair/sanitization extension.
|
||||||
|
*
|
||||||
|
* Runs on every context build to prevent strict provider request rejections:
|
||||||
|
* - duplicate or displaced tool results (Anthropic-compatible APIs, MiniMax, Cloud Code Assist)
|
||||||
|
* - Cloud Code Assist tool call ID constraints + collision-safe sanitization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
import { repairToolUseResultPairing } from "../session-transcript-repair.js";
|
||||||
|
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
|
||||||
|
import { resolveTranscriptPolicy } from "../transcript-policy.js";
|
||||||
|
|
||||||
|
export default function transcriptSanitizeExtension(api: ExtensionAPI): void {
|
||||||
|
api.on("context", (event: ContextEvent, ctx: ExtensionContext) => {
|
||||||
|
let next = event.messages as AgentMessage[];
|
||||||
|
|
||||||
|
const policy = resolveTranscriptPolicy({
|
||||||
|
modelApi: ctx.model?.api,
|
||||||
|
provider: ctx.model?.provider,
|
||||||
|
modelId: ctx.model?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (policy.repairToolUseResultPairing) {
|
||||||
|
const repaired = repairToolUseResultPairing(next);
|
||||||
|
if (repaired.messages !== next) next = repaired.messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.sanitizeToolCallIds) {
|
||||||
|
const repairedIds = sanitizeToolCallIdsForCloudCodeAssist(
|
||||||
|
next,
|
||||||
|
policy.toolCallIdMode ?? "strict",
|
||||||
|
);
|
||||||
|
if (repairedIds !== next) next = repairedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === event.messages) return undefined;
|
||||||
|
return { messages: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export function guardSessionManager(
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
allowSyntheticToolResults?: boolean;
|
allowSyntheticToolResults?: boolean;
|
||||||
|
stripFinalTags?: boolean;
|
||||||
},
|
},
|
||||||
): GuardedSessionManager {
|
): GuardedSessionManager {
|
||||||
if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") {
|
if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") {
|
||||||
@@ -48,6 +49,7 @@ export function guardSessionManager(
|
|||||||
const guard = installSessionToolResultGuard(sessionManager, {
|
const guard = installSessionToolResultGuard(sessionManager, {
|
||||||
transformToolResultForPersistence: transform,
|
transformToolResultForPersistence: transform,
|
||||||
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
|
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
|
||||||
|
stripFinalTags: opts?.stripFinalTags,
|
||||||
});
|
});
|
||||||
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
||||||
return sessionManager as GuardedSessionManager;
|
return sessionManager as GuardedSessionManager;
|
||||||
|
|||||||
@@ -142,4 +142,26 @@ describe("installSessionToolResultGuard", () => {
|
|||||||
.map((e) => (e as { message: AgentMessage }).message);
|
.map((e) => (e as { message: AgentMessage }).message);
|
||||||
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips <final> tags from assistant text blocks", () => {
|
||||||
|
const sm = SessionManager.inMemory();
|
||||||
|
installSessionToolResultGuard(sm);
|
||||||
|
|
||||||
|
sm.appendMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "<final>Hey!</final>" },
|
||||||
|
{ type: "text", text: "More <final>text</final> here." },
|
||||||
|
],
|
||||||
|
} as AgentMessage);
|
||||||
|
|
||||||
|
const messages = sm
|
||||||
|
.getEntries()
|
||||||
|
.filter((e) => e.type === "message")
|
||||||
|
.map((e) => (e as { message: AgentMessage }).message);
|
||||||
|
|
||||||
|
const assistant = messages[0] as { content?: Array<{ type?: string; text?: string }> };
|
||||||
|
expect(assistant.content?.[0]?.text).toBe("Hey!");
|
||||||
|
expect(assistant.content?.[1]?.text).toBe("More text here.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -6,6 +6,41 @@ import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
|||||||
|
|
||||||
type ToolCall = { id: string; name?: string };
|
type ToolCall = { id: string; name?: string };
|
||||||
|
|
||||||
|
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
|
||||||
|
|
||||||
|
function stripFinalTagsFromText(text: string): string {
|
||||||
|
if (!text) return text;
|
||||||
|
return text.replace(FINAL_TAG_RE, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFinalTagsFromAssistant(message: Extract<AgentMessage, { role: "assistant" }>) {
|
||||||
|
const content = message.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
const cleaned = stripFinalTagsFromText(content);
|
||||||
|
return cleaned === content
|
||||||
|
? message
|
||||||
|
: ({ ...message, content: cleaned } as unknown as AgentMessage);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(content)) return message;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const next = content.map((block) => {
|
||||||
|
if (!block || typeof block !== "object") return block;
|
||||||
|
const record = block as { type?: unknown; text?: unknown };
|
||||||
|
if (record.type === "text" && typeof record.text === "string") {
|
||||||
|
const cleaned = stripFinalTagsFromText(record.text);
|
||||||
|
if (cleaned !== record.text) {
|
||||||
|
changed = true;
|
||||||
|
return { ...record, text: cleaned };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return message;
|
||||||
|
return { ...message, content: next } as AgentMessage;
|
||||||
|
}
|
||||||
|
|
||||||
function extractAssistantToolCalls(msg: Extract<AgentMessage, { role: "assistant" }>): ToolCall[] {
|
function extractAssistantToolCalls(msg: Extract<AgentMessage, { role: "assistant" }>): ToolCall[] {
|
||||||
const content = msg.content;
|
const content = msg.content;
|
||||||
if (!Array.isArray(content)) return [];
|
if (!Array.isArray(content)) return [];
|
||||||
@@ -44,6 +79,11 @@ export function installSessionToolResultGuard(
|
|||||||
message: AgentMessage,
|
message: AgentMessage,
|
||||||
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
||||||
) => AgentMessage;
|
) => AgentMessage;
|
||||||
|
/**
|
||||||
|
* Whether to strip <final> tags from assistant text before persistence.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
stripFinalTags?: boolean;
|
||||||
/**
|
/**
|
||||||
* Whether to synthesize missing tool results to satisfy strict providers.
|
* Whether to synthesize missing tool results to satisfy strict providers.
|
||||||
* Defaults to true.
|
* Defaults to true.
|
||||||
@@ -66,6 +106,7 @@ export function installSessionToolResultGuard(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
|
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
|
||||||
|
const stripFinalTags = opts?.stripFinalTags ?? true;
|
||||||
|
|
||||||
const flushPendingToolResults = () => {
|
const flushPendingToolResults = () => {
|
||||||
if (pending.size === 0) return;
|
if (pending.size === 0) return;
|
||||||
@@ -100,9 +141,13 @@ export function installSessionToolResultGuard(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitized =
|
||||||
|
role === "assistant" && stripFinalTags
|
||||||
|
? stripFinalTagsFromAssistant(message as Extract<AgentMessage, { role: "assistant" }>)
|
||||||
|
: message;
|
||||||
const toolCalls =
|
const toolCalls =
|
||||||
role === "assistant"
|
role === "assistant"
|
||||||
? extractAssistantToolCalls(message as Extract<AgentMessage, { role: "assistant" }>)
|
? extractAssistantToolCalls(sanitized as Extract<AgentMessage, { role: "assistant" }>)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (allowSyntheticToolResults) {
|
if (allowSyntheticToolResults) {
|
||||||
@@ -116,7 +161,7 @@ export function installSessionToolResultGuard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = originalAppend(message as never);
|
const result = originalAppend(sanitized as never);
|
||||||
|
|
||||||
const sessionFile = (
|
const sessionFile = (
|
||||||
sessionManager as { getSessionFile?: () => string | null }
|
sessionManager as { getSessionFile?: () => string | null }
|
||||||
|
|||||||
@@ -86,64 +86,6 @@ describe("message tool mirroring", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("message tool path passthrough", () => {
|
|
||||||
it("does not convert path to media for send", async () => {
|
|
||||||
mocks.runMessageAction.mockClear();
|
|
||||||
mocks.runMessageAction.mockResolvedValue({
|
|
||||||
kind: "send",
|
|
||||||
action: "send",
|
|
||||||
channel: "telegram",
|
|
||||||
to: "telegram:123",
|
|
||||||
handledBy: "plugin",
|
|
||||||
payload: {},
|
|
||||||
dryRun: true,
|
|
||||||
} satisfies MessageActionRunResult);
|
|
||||||
|
|
||||||
const tool = createMessageTool({
|
|
||||||
config: {} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await tool.execute("1", {
|
|
||||||
action: "send",
|
|
||||||
target: "telegram:123",
|
|
||||||
path: "~/Downloads/voice.ogg",
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
|
||||||
expect(call?.params?.path).toBe("~/Downloads/voice.ogg");
|
|
||||||
expect(call?.params?.media).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not convert filePath to media for send", async () => {
|
|
||||||
mocks.runMessageAction.mockClear();
|
|
||||||
mocks.runMessageAction.mockResolvedValue({
|
|
||||||
kind: "send",
|
|
||||||
action: "send",
|
|
||||||
channel: "telegram",
|
|
||||||
to: "telegram:123",
|
|
||||||
handledBy: "plugin",
|
|
||||||
payload: {},
|
|
||||||
dryRun: true,
|
|
||||||
} satisfies MessageActionRunResult);
|
|
||||||
|
|
||||||
const tool = createMessageTool({
|
|
||||||
config: {} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await tool.execute("1", {
|
|
||||||
action: "send",
|
|
||||||
target: "telegram:123",
|
|
||||||
filePath: "./tmp/note.m4a",
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
|
||||||
expect(call?.params?.filePath).toBe("./tmp/note.m4a");
|
|
||||||
expect(call?.params?.media).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("message tool description", () => {
|
describe("message tool description", () => {
|
||||||
const bluebubblesPlugin: ChannelPlugin = {
|
const bluebubblesPlugin: ChannelPlugin = {
|
||||||
id: "bluebubbles",
|
id: "bluebubbles",
|
||||||
|
|||||||
@@ -340,7 +340,6 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
const action = readStringParam(params, "action", {
|
const action = readStringParam(params, "action", {
|
||||||
required: true,
|
required: true,
|
||||||
}) as ChannelMessageActionName;
|
}) as ChannelMessageActionName;
|
||||||
|
|
||||||
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
||||||
|
|
||||||
const gateway = {
|
const gateway = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type TranscriptPolicy = {
|
|||||||
sanitizeToolCallIds: boolean;
|
sanitizeToolCallIds: boolean;
|
||||||
toolCallIdMode?: ToolCallIdMode;
|
toolCallIdMode?: ToolCallIdMode;
|
||||||
repairToolUseResultPairing: boolean;
|
repairToolUseResultPairing: boolean;
|
||||||
|
enforceToolCallLast: boolean;
|
||||||
preserveSignatures: boolean;
|
preserveSignatures: boolean;
|
||||||
sanitizeThoughtSignatures?: {
|
sanitizeThoughtSignatures?: {
|
||||||
allowBase64Only?: boolean;
|
allowBase64Only?: boolean;
|
||||||
@@ -18,6 +19,7 @@ export type TranscriptPolicy = {
|
|||||||
applyGoogleTurnOrdering: boolean;
|
applyGoogleTurnOrdering: boolean;
|
||||||
validateGeminiTurns: boolean;
|
validateGeminiTurns: boolean;
|
||||||
validateAnthropicTurns: boolean;
|
validateAnthropicTurns: boolean;
|
||||||
|
stripFinalTags: boolean;
|
||||||
allowSyntheticToolResults: boolean;
|
allowSyntheticToolResults: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ export function resolveTranscriptPolicy(params: {
|
|||||||
? "strict"
|
? "strict"
|
||||||
: undefined;
|
: undefined;
|
||||||
const repairToolUseResultPairing = isGoogle || isAnthropic;
|
const repairToolUseResultPairing = isGoogle || isAnthropic;
|
||||||
|
const enforceToolCallLast = isAnthropic;
|
||||||
const sanitizeThoughtSignatures = isOpenRouterGemini
|
const sanitizeThoughtSignatures = isOpenRouterGemini
|
||||||
? { allowBase64Only: true, includeCamelCase: true }
|
? { allowBase64Only: true, includeCamelCase: true }
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -101,12 +104,14 @@ export function resolveTranscriptPolicy(params: {
|
|||||||
sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds,
|
sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds,
|
||||||
toolCallIdMode,
|
toolCallIdMode,
|
||||||
repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing,
|
repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing,
|
||||||
|
enforceToolCallLast: !isOpenAi && enforceToolCallLast,
|
||||||
preserveSignatures: isAntigravityClaudeModel,
|
preserveSignatures: isAntigravityClaudeModel,
|
||||||
sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures,
|
sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures,
|
||||||
normalizeAntigravityThinkingBlocks,
|
normalizeAntigravityThinkingBlocks,
|
||||||
applyGoogleTurnOrdering: !isOpenAi && isGoogle,
|
applyGoogleTurnOrdering: !isOpenAi && isGoogle,
|
||||||
validateGeminiTurns: !isOpenAi && isGoogle,
|
validateGeminiTurns: !isOpenAi && isGoogle,
|
||||||
validateAnthropicTurns: !isOpenAi && isAnthropic,
|
validateAnthropicTurns: !isOpenAi && isAnthropic,
|
||||||
|
stripFinalTags: !isOpenAi && (isGoogle || isAnthropic),
|
||||||
allowSyntheticToolResults: !isOpenAi && (isGoogle || isAnthropic),
|
allowSyntheticToolResults: !isOpenAi && (isGoogle || isAnthropic),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ export async function callGatewayFromCli(
|
|||||||
params?: unknown,
|
params?: unknown,
|
||||||
extra?: { expectFinal?: boolean; progress?: boolean },
|
extra?: { expectFinal?: boolean; progress?: boolean },
|
||||||
) {
|
) {
|
||||||
const showProgress = extra?.progress ?? opts.json !== true;
|
const showProgress = (extra?.progress ?? true) && opts.json !== true;
|
||||||
return await withProgress(
|
return await withProgress(
|
||||||
{
|
{
|
||||||
label: `Gateway ${method}`,
|
label: `Gateway ${method}`,
|
||||||
|
|||||||
@@ -82,4 +82,21 @@ describe("logs cli", () => {
|
|||||||
|
|
||||||
expect(stderrWrites.join("")).toContain("output stdout closed");
|
expect(stderrWrites.join("")).toContain("output stdout closed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables progress spinner in json mode", async () => {
|
||||||
|
callGatewayFromCli.mockResolvedValueOnce({
|
||||||
|
file: "/tmp/clawdbot.log",
|
||||||
|
lines: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registerLogsCli } = await import("./logs-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerLogsCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["logs", "--json"], { from: "user" });
|
||||||
|
|
||||||
|
const extra = callGatewayFromCli.mock.calls[0]?.[3];
|
||||||
|
expect(extra).toEqual({ progress: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+2
-2
@@ -196,8 +196,8 @@ export function registerLogsCli(program: Command) {
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let payload: LogsTailPayload;
|
let payload: LogsTailPayload;
|
||||||
// Show progress spinner only on first fetch, not during follow polling
|
// Show progress spinner only on first fetch for non-follow, non-JSON output.
|
||||||
const showProgress = first && !opts.follow;
|
const showProgress = first && !opts.follow && !jsonMode;
|
||||||
try {
|
try {
|
||||||
payload = await fetchLogs(opts, cursor, showProgress);
|
payload = await fetchLogs(opts, cursor, showProgress);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
|
if (/^(user|discord|slack|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
|
||||||
return "user";
|
return "user";
|
||||||
}
|
}
|
||||||
return "group";
|
return "group";
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ 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,6 +124,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
|||||||
"telegram",
|
"telegram",
|
||||||
"discord",
|
"discord",
|
||||||
"slack",
|
"slack",
|
||||||
|
"mattermost",
|
||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
"msteams",
|
"msteams",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ 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,5 +1,6 @@
|
|||||||
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";
|
||||||
@@ -17,6 +18,7 @@ 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,6 +24,7 @@ export type HookMappingConfig = {
|
|||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
| "slack"
|
| "slack"
|
||||||
|
| "mattermost"
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
| "msteams";
|
| "msteams";
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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,6 +13,7 @@ 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,6 +14,7 @@ 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,6 +29,7 @@ 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,6 +23,7 @@ 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,6 +367,27 @@ 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,6 +4,7 @@ import {
|
|||||||
BlueBubblesConfigSchema,
|
BlueBubblesConfigSchema,
|
||||||
DiscordConfigSchema,
|
DiscordConfigSchema,
|
||||||
IMessageConfigSchema,
|
IMessageConfigSchema,
|
||||||
|
MattermostConfigSchema,
|
||||||
MSTeamsConfigSchema,
|
MSTeamsConfigSchema,
|
||||||
SignalConfigSchema,
|
SignalConfigSchema,
|
||||||
SlackConfigSchema,
|
SlackConfigSchema,
|
||||||
@@ -27,6 +28,7 @@ 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,11 +28,18 @@ 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,6 +22,7 @@ const MARKDOWN_CAPABLE_CHANNELS = new Set<string>([
|
|||||||
"telegram",
|
"telegram",
|
||||||
"signal",
|
"signal",
|
||||||
"discord",
|
"discord",
|
||||||
|
"mattermost",
|
||||||
"tui",
|
"tui",
|
||||||
INTERNAL_MESSAGE_CHANNEL,
|
INTERNAL_MESSAGE_CHANNEL,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -164,6 +164,39 @@ 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;
|
||||||
@@ -382,6 +415,7 @@ export type CronPayload =
|
|||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
| "slack"
|
| "slack"
|
||||||
|
| "mattermost"
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
| "msteams";
|
| "msteams";
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { html, nothing } from "lit";
|
||||||
|
|
||||||
|
import { formatAgo } from "../format";
|
||||||
|
import type { MattermostStatus } from "../types";
|
||||||
|
import type { ChannelsProps } from "./channels.types";
|
||||||
|
import { renderChannelConfigSection } from "./channels.config";
|
||||||
|
|
||||||
|
export function renderMattermostCard(params: {
|
||||||
|
props: ChannelsProps;
|
||||||
|
mattermost?: MattermostStatus | null;
|
||||||
|
accountCountLabel: unknown;
|
||||||
|
}) {
|
||||||
|
const { props, mattermost, accountCountLabel } = params;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Mattermost</div>
|
||||||
|
<div class="card-sub">Bot token + WebSocket status and configuration.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
|
<div>
|
||||||
|
<span class="label">Configured</span>
|
||||||
|
<span>${mattermost?.configured ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Running</span>
|
||||||
|
<span>${mattermost?.running ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Connected</span>
|
||||||
|
<span>${mattermost?.connected ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Base URL</span>
|
||||||
|
<span>${mattermost?.baseUrl || "n/a"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Last start</span>
|
||||||
|
<span>${mattermost?.lastStartAt ? formatAgo(mattermost.lastStartAt) : "n/a"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Last probe</span>
|
||||||
|
<span>${mattermost?.lastProbeAt ? formatAgo(mattermost.lastProbeAt) : "n/a"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${mattermost?.lastError
|
||||||
|
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||||
|
${mattermost.lastError}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
${mattermost?.probe
|
||||||
|
? html`<div class="callout" style="margin-top: 12px;">
|
||||||
|
Probe ${mattermost.probe.ok ? "ok" : "failed"} -
|
||||||
|
${mattermost.probe.status ?? ""} ${mattermost.probe.error ?? ""}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
${renderChannelConfigSection({ channelId: "mattermost", props })}
|
||||||
|
|
||||||
|
<div class="row" style="margin-top: 12px;">
|
||||||
|
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||||
|
Probe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ChannelsStatusSnapshot,
|
ChannelsStatusSnapshot,
|
||||||
DiscordStatus,
|
DiscordStatus,
|
||||||
IMessageStatus,
|
IMessageStatus,
|
||||||
|
MattermostStatus,
|
||||||
NostrProfile,
|
NostrProfile,
|
||||||
NostrStatus,
|
NostrStatus,
|
||||||
SignalStatus,
|
SignalStatus,
|
||||||
@@ -23,6 +24,7 @@ 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";
|
||||||
@@ -39,6 +41,7 @@ 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;
|
||||||
@@ -62,6 +65,7 @@ export function renderChannels(props: ChannelsProps) {
|
|||||||
telegram,
|
telegram,
|
||||||
discord,
|
discord,
|
||||||
slack,
|
slack,
|
||||||
|
mattermost,
|
||||||
signal,
|
signal,
|
||||||
imessage,
|
imessage,
|
||||||
nostr,
|
nostr,
|
||||||
@@ -135,6 +139,12 @@ 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,6 +4,7 @@ import type {
|
|||||||
ConfigUiHints,
|
ConfigUiHints,
|
||||||
DiscordStatus,
|
DiscordStatus,
|
||||||
IMessageStatus,
|
IMessageStatus,
|
||||||
|
MattermostStatus,
|
||||||
NostrProfile,
|
NostrProfile,
|
||||||
NostrStatus,
|
NostrStatus,
|
||||||
SignalStatus,
|
SignalStatus,
|
||||||
@@ -53,6 +54,7 @@ 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