Summary
The chat_type OpenClaw reports for a Mattermost private channel is non-deterministic across code paths: it is group when derived from the channel type, but channel when derived from the delivery target string. The same conversation can therefore be keyed under two different session namespaces.
Related: #95645 — the system-prompt-intro half of the same "group" overload. This issue is the deeper derivation/routing half.
Two derivation strategies that disagree
// By channel type — AUTHORITATIVE, correct (monitor-auth-*.js / channel.runtime-*.js)
mapMattermostChannelKind(type): D -> direct ; G or P -> group ; else (O) -> channel
// By target-string prefix — LOSSY (targets-*.js / subagent-announce-delivery-*.js)
inferChatTypeFromTarget({to}): /^user:/ -> direct ; /^(channel:|thread:)/ -> channel ; /^group:/ -> group
A private channel (P) is addressed as channel:<id> — the same prefix as a public channel — yet its authoritative type is group. So the prefix path returns channel while the type path returns group. Mattermost's own resolveDeliveryTarget always emits to: channel:${id} (even for private channels), which is the origin of the lossy prefix.
OpenClaw already provides a per-plugin extension point for exactly this — messaging.inferTargetChatType({to}) — and Telegram, Discord, Signal, iMessage and ClickClack implement it. Mattermost does not, so its target → chat_type inference falls back to the lossy generic default. (Also note: the generic inferChatTypeFromTarget returns at the channel: rule before it would reach the plugin hook, so even implementing the Mattermost hook needs the generic order changed too.)
Telegram is a clean model: it carries chatType on the route target (derived from the authoritative id), instead of re-deriving it from a string later.
Impact
A wrong chat_type mis-addresses the conversation. With session keys
agent:<id>:mattermost:<direct|channel|group>:<peerId>:thread:<root>, a private channel ends up under both
group:<id>:thread:<root> (inbound, type-based) and a phantom channel:<id>:thread:<root> (delivery,
prefix-based). Threaded/scheduled deliveries bound to one namespace then fail to match the other (fail-closed
delivery, or a conversation split across two session keys).
Repro
In a Mattermost private channel, observe the same thread keyed under both group:<id>:thread:<root>
(inbound classification) and channel:<id>:thread:<root> (a delivery-side session).
Proposed fix
- Implement Mattermost's
messaging.inferTargetChatType hook, resolving channel-vs-group from the real
channel type — the pieces already exist (mapMattermostChannelTypeToChatType,
resolveMattermostTrustedChatKind, and a TTL channelCache).
- Make the generic
inferChatTypeFromTarget / inferDeliveryTargetChatType consult the plugin hook
before the channel: ⇒ channel default (today the channel: rule returns first, so the hook is never
reached for a channel: target). Or adopt Telegram's "carry chatType on the route target" pattern so no
re-inference from a lossy string is needed.
Note
We confirmed the type-resolution approach end-to-end downstream: resolving the channel type via
GET /api/v4/channels/<id> (O→channel, P/G→group, D→direct) and using that instead of the
target-string inference fixes the mis-classification.
Environment
ghcr.io/openclaw/openclaw:2026.6.5. Relevant dist files: monitor-auth-*.js, channel.runtime-*.js,
channel-plugin-runtime-*.js, targets-*.js, subagent-announce-delivery-*.js.
Summary
The
chat_typeOpenClaw reports for a Mattermost private channel is non-deterministic across code paths: it isgroupwhen derived from the channel type, butchannelwhen derived from the delivery target string. The same conversation can therefore be keyed under two different session namespaces.Two derivation strategies that disagree
A private channel (
P) is addressed aschannel:<id>— the same prefix as a public channel — yet its authoritative type isgroup. So the prefix path returnschannelwhile the type path returnsgroup. Mattermost's ownresolveDeliveryTargetalways emitsto: channel:${id}(even for private channels), which is the origin of the lossy prefix.OpenClaw already provides a per-plugin extension point for exactly this —
messaging.inferTargetChatType({to})— and Telegram, Discord, Signal, iMessage and ClickClack implement it. Mattermost does not, so its target → chat_type inference falls back to the lossy generic default. (Also note: the genericinferChatTypeFromTargetreturns at thechannel:rule before it would reach the plugin hook, so even implementing the Mattermost hook needs the generic order changed too.)Telegram is a clean model: it carries
chatTypeon the route target (derived from the authoritative id), instead of re-deriving it from a string later.Impact
A wrong
chat_typemis-addresses the conversation. With session keysagent:<id>:mattermost:<direct|channel|group>:<peerId>:thread:<root>, a private channel ends up under bothgroup:<id>:thread:<root>(inbound, type-based) and a phantomchannel:<id>:thread:<root>(delivery,prefix-based). Threaded/scheduled deliveries bound to one namespace then fail to match the other (fail-closed
delivery, or a conversation split across two session keys).
Repro
In a Mattermost private channel, observe the same thread keyed under both
group:<id>:thread:<root>(inbound classification) and
channel:<id>:thread:<root>(a delivery-side session).Proposed fix
messaging.inferTargetChatTypehook, resolving channel-vs-group from the realchannel type — the pieces already exist (
mapMattermostChannelTypeToChatType,resolveMattermostTrustedChatKind, and a TTLchannelCache).inferChatTypeFromTarget/inferDeliveryTargetChatTypeconsult the plugin hookbefore the
channel:⇒ channel default (today thechannel:rule returns first, so the hook is neverreached for a
channel:target). Or adopt Telegram's "carrychatTypeon the route target" pattern so nore-inference from a lossy string is needed.
Note
We confirmed the type-resolution approach end-to-end downstream: resolving the channel type via
GET /api/v4/channels/<id>(O→channel,P/G→group,D→direct) and using that instead of thetarget-string inference fixes the mis-classification.
Environment
ghcr.io/openclaw/openclaw:2026.6.5. Relevant dist files:monitor-auth-*.js,channel.runtime-*.js,channel-plugin-runtime-*.js,targets-*.js,subagent-announce-delivery-*.js.