Mattermost Plugin: Agent Not Waking on Reply to Bot's Own Message
The Mattermost plugin does not trigger agent wake events when users reply to bot-authored messages in threads, unlike Telegram which handles this natively. This guide covers root cause analysis and implementation steps.
π Symptoms
Observable Behavior
When a user replies to a bot’s own message in a Mattermost thread, the agent does not respond:
Channel: #support-pipeline
Thread Structure:
βββ [Bot] Sage: "Created bug ticket #1234 for memory leak"
β βββ [User] Mike: "@agent Can you assign this to sprint-5?"
β βββ [Bot] Agent: "Assigned to sprint-5 β"
β βββ [User] Mike: "Looks good, thanks!" β Agent DOES NOT RESPOND
β βββ [User] Mike: "@agent close the ticket" β Requires @mention
Current Wake Trigger Modes
The plugin supports three chat modes with these exact behaviors:
| Mode | Wake Condition | Reply-to-Bot |
|---|---|---|
oncall (default) | @mention only | β No wake |
onmessage | Every message | β οΈ Wakes but spam |
off | Disabled | β No wake |
Observed Error State
When running with chatmode: oncall, reply-to-bot messages produce zero agent activity:
$ openclaw channel history --channel support-pipeline --limit 5
[
{
"post_id": "abc123",
"user_id": "bot-user-id",
"type": "system",
"message": "Sage: Created bug ticket #1234"
},
{
"post_id": "def456",
"user_id": "human-mike",
"root_id": "abc123",
"message": "Looks good, thanks!" β Agent did not process
}
]
$ openclaw agent status
{
"state": "idle",
"last_wake": "2024-01-15T10:30:00Z",
"wake_reason": "mention:@agent",
"messages_processed": 0
}
Telegram Comparison (Expected Behavior)
Telegram handles this scenario natively with correct routing:
Telegram Chat:
βββ Bot: "Created bug ticket #1234"
β βββ User: "Looks good, thanks!" β Bot receives update
β βββ Bot: "Acknowledged! Any other issues?"
π§ Root Cause
Architectural Gap in Mattermost Plugin
The root cause lies in how the Mattermost event handler processes incoming webhook payloads. The plugin currently performs a single trigger condition check:
Current Code Path (Simplified)
// File: mattermost/plugin.go (lines 87-112)
func (p *Plugin) handleMessageEvent(event *model.WebSocketEvent) error {
post := model.PostFromJson(event.GetData())
// Current implementation: ONLY checks for @mention
if !containsMention(post.Message, p.botUserName) {
p.API.LogDebug("Message ignored: no mention")
return nil // β Early return for reply-to-bot messages
}
// Wake agent path
return p.wakeAgent(post)
}
Missing Parent Post Resolution
The critical omission is that the handler does not inspect the message’s thread context:
| Field | Purpose | Current Handling |
|---|---|---|
post.RootId | Original post in thread | β Not checked |
post.ParentId | Direct parent in thread | β Not checked |
post.UserId | Author of current message | β Checked for @mention |
Message Flow Comparison
Telegram (Correct Behavior):
User replies to bot message
β
Telegram API includes "reply_to_message_id"
β
Bot receives update with reply context
β
Framework resolves parent message
β
Agent woken for contextual reply
Mattermost (Current Broken Path):
User replies to bot message
β
Mattermost sends WebSocket event with RootId/ParentId
β
Plugin handler receives event
β
Checks: containsMention(message, botUsername)
β
Returns nil (no mention found)
β
Agent remains idle β PROBLEM
Configuration Constraint
The oncall mode was designed with the assumption that Mattermost requires explicit @mention for all bot interactions. This design decision predates the agent-to-agent workflow use case and thread-based collaboration patterns.
Database Query Gap
The plugin does not perform a parent post lookup to determine authorship:
-- Missing query pattern:
SELECT user_id FROM posts WHERE id = $parent_id;
-- Current pattern (only checks current message):
-- No database access for parent resolution
π οΈ Step-by-Step Fix
Implementation Overview
The fix requires modifying the Mattermost plugin’s event handler to perform parent post resolution and authorship checking before making the wake decision.
Step 1: Update Configuration Schema
Add explicit option for reply-to-bot behavior:
// File: config/schema.go
type MattermostConfig struct {
// ... existing fields ...
// WakeOnReplyToSelf controls whether the agent wakes when users
// reply to messages authored by the bot itself.
// Applies only when ChatMode is "oncall".
WakeOnReplyToSelf bool `json:"wakeOnReplyToSelf" env:"MM_WAKE_ON_REPLY"`
// Alternative: Change default behavior in oncall mode
// ChatMode: "oncall" // "oncall" | "onmessage" | "off"
}
Step 2: Modify Event Handler Logic
Replace the single-mention check with a multi-condition evaluation:
// File: mattermost/plugin.go
func (p *Plugin) handleMessageEvent(event *model.WebSocketEvent) error {
post := model.PostFromJson(event.GetData())
// Skip if message is from the bot itself (avoid self-wake loops)
if post.UserId == p.botUserID {
p.API.LogDebug("Message skipped: from bot")
return nil
}
// Condition 1: Direct @mention (existing behavior)
if containsMention(post.Message, p.botUserName) {
return p.wakeAgentWithReason(post, "mention")
}
// Condition 2: Reply to bot's own message (NEW)
if p.shouldWakeOnReply(post) {
return p.wakeAgentWithReason(post, "reply_to_bot")
}
// No wake trigger
return nil
}
// NEW: Determine if message is a reply to bot's post
func (p *Plugin) shouldWakeOnReply(post *model.Post) bool {
// Check if in a thread
if post.RootId == "" && post.ParentId == "" {
return false
}
// Get parent post ID
parentID := post.RootId
if parentID == "" {
parentID = post.ParentId
}
// Fetch parent post from Mattermost API
parentPost, err := p.API.GetPost(parentID)
if err != nil {
p.API.LogError("Failed to fetch parent post", "post_id", parentID, "error", err)
return false
}
// Check if parent was authored by the bot
return parentPost.UserId == p.botUserID
}
Step 3: Update Wake Agent Signature
// File: mattermost/plugin.go
func (p *Plugin) wakeAgentWithReason(post *model.Post, reason string) error {
p.API.LogInfo("Waking agent",
"reason", reason,
"post_id", post.Id,
"channel_id", post.ChannelId,
"root_id", post.RootId,
)
// Include thread context in wake payload
ctx := map[string]interface{}{
"reason": reason,
"root_id": post.RootId,
"parent_id": post.ParentId,
"channel_id": post.ChannelId,
"is_thread": post.RootId != "",
}
return p.agent.Wake(ctx)
}
Step 4: Environment Variable Configuration
# Add to deployment configuration
export MM_WAKE_ON_REPLY=true
# or via docker-compose.yml
environment:
- MM_WAKE_ON_REPLY=true
Before vs After Configuration
Before (Current Default):
# config.yaml
mattermost:
chatmode: "oncall"
# No reply-to-bot handling
# Result: Only @mention wakes agent
After (Proposed Default):
# config.yaml
mattermost:
chatmode: "oncall"
wake_on_reply_to_self: true # NEW - default true
# Result: @mention OR reply-to-bot wakes agent
π§ͺ Verification
Test Suite Implementation
Create integration tests to verify the new behavior:
// File: mattermost/plugin_reply_test.go
func TestWakeOnReplyToBot(t *testing.T) {
plugin := setupTestPlugin()
botUserID := "bot-uuid-123"
userID := "user-uuid-456"
channelID := "channel-uuid-789"
tests := []struct {
name string
parentAuthor string
message string
expectedWake bool
expectedReason string
}{
{
name: "Reply to bot message should wake",
parentAuthor: botUserID,
message: "Thanks for the update!",
expectedWake: true,
expectedReason: "reply_to_bot",
},
{
name: "Reply to human message should not wake",
parentAuthor: userID,
message: "Looks good to me too",
expectedWake: false,
expectedReason: "",
},
{
name: "@mention should still wake",
parentAuthor: userID,
message: "@agent check this",
expectedWake: true,
expectedReason: "mention",
},
{
name: "Inline reply to bot should wake",
parentAuthor: botUserID,
message: "Confirmed β",
expectedWake: true,
expectedReason: "reply_to_bot",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
post := createMockPost(map[string]interface{}{
"UserId": userID,
"ChannelId": channelID,
"Message": tt.message,
"RootId": "root-post-123",
})
// Mock parent post
parentPost := createMockPost(map[string]interface{}{
"Id": "root-post-123",
"UserId": tt.parentAuthor,
})
plugin.mockAPI.On("GetPost", "root-post-123").Return(parentPost, nil)
result := plugin.handleMessageEvent(post)
if tt.expectedWake {
require.NoError(t, result)
plugin.mockAgent.AssertCalled(t, "Wake", mock.Anything)
} else {
plugin.mockAgent.AssertNotCalled(t, "Wake", mock.Anything)
}
})
}
}
Manual Verification Steps
Step 1: Verify Plugin Version
$ openclaw version
OpenClaw v2.4.0 (mattermost plugin: v2.4.0-rc1)
$ openclaw plugin list
NAME VERSION STATUS
mattermost 2.4.0-rc1 active
Step 2: Enable Debug Logging
$ openclaw config set log_level debug
Configuration updated
$ tail -f /var/log/openclaw/agent.log | grep -E "(reply|parent|bot)"
DEBUG plugin:mattermost checking message reply context post_id=def456 parent_id=abc123
DEBUG plugin:mattermost parent post author matches bot user_id=bot-uuid
DEBUG plugin:mattermost waking agent reason=reply_to_bot
Step 3: Test Reply-to-Bot Scenario
# 1. Bot posts initial message
$ openclaw message send --channel support-pipeline --text "Bug #123 created"
{
"post_id": "abc123",
"timestamp": "2024-01-15T10:30:00Z"
}
# 2. User replies in thread
# (Perform this action in Mattermost client UI)
# 3. Verify agent wake event
$ openclaw agent status
{
"state": "active",
"last_wake": "2024-01-15T10:31:15Z",
"wake_reason": "reply_to_bot",
"last_message_id": "def456"
}
# 4. Verify context propagation
$ openclaw thread context --post-id def456
{
"root_id": "abc123",
"parent_id": "abc123",
"bot_original_message": "Bug #123 created",
"reply_text": "Looks good, thanks!"
}
Step 4: Verify Thread vs Inline Reply
Both scenarios should trigger wake:
# Threaded reply (root_id populated)
{
"post_id": "def456",
"root_id": "abc123", // β Points to bot's message
"parent_id": "abc123",
"message": "Confirmed β"
}
# Result: β
Agent wakes
# Inline reply (root_id empty, parent_id set)
{
"post_id": "ghi789",
"root_id": "",
"parent_id": "abc123", // β Points to bot's message
"message": "Got it"
}
# Result: β
Agent wakes (with fix)
Exit Code Verification
$ openclaw verify --channel support-pipeline --test reply-to-bot
Running verification test: reply-to-bot
β Connected to Mattermost (v7.8.0)
β Plugin version: 2.4.0-rc1
β Bot user authenticated
β Reply-to-bot detection: ENABLED
Test message sent. Awaiting response...
β Agent woke within 5s (reason: reply_to_bot)
β Response delivered in thread
VERIFICATION PASSED (exit code 0)
β οΈ Common Pitfalls
1. Self-Wake Loop Prevention
Pitfall: Bot may trigger itself if the wake logic doesn’t exclude bot-authored messages.
Symptom:
ERROR Agent loop detected: 50 wake events in 10 seconds
WARN Halting agent to prevent infinite loop
Mitigation:
// CRITICAL: Always check post authorship before wake
if post.UserId == p.botUserID {
p.API.LogDebug("Skipping: message from bot")
return nil // Do NOT wake on bot's own messages
}
2. Thread Depth Navigation
Pitfall: Parent post lookup only retrieves direct parent, not root message.
Scenario:
[Bot] Message A (root)
βββ [User] Message B (reply to A)
βββ [User] Message C (reply to B) β Bot NOT in this chain
Behavior: Message C should NOT wake the agent (correct). Only replies where the bot authored the target message should trigger wake.
3. Mattermost API Rate Limiting
Pitfall: Parent post lookup via GetPost() may hit rate limits in high-traffic channels.
Solution: Implement caching for parent post lookups:
var parentPostCache sync.Map
func (p *Plugin) getCachedParentPost(postID string) (*model.Post, error) {
if cached, ok := parentPostCache.Load(postID); ok {
return cached.(*model.Post), nil
}
post, err := p.API.GetPost(postID)
if err == nil {
parentPostCache.Store(postID, post)
// TTL: 5 minutes
go func() {
time.Sleep(5 * time.Minute)
parentPostCache.Delete(postID)
}()
}
return post, err
}
4. Direct Message Channels
Pitfall: In DM channels, every message is implicitly directed at the bot.
Edge Case:
User starts DM with bot
βββ Bot: "Hello! How can I help?"
β βββ User: "Thanks" β Should this wake?
Recommendation: For DM channels, maintain current @mention behavior or configure explicitly:
mattermost:
dm_behavior: "mention_only" # Don't auto-wake on DM replies
5. Integration with onmessage Mode
Pitfall: With onmessage, all messages wake the agent, including reply-to-bot.
Conflict:
# If user sets chatmode: "onmessage"
# Reply-to-bot will wake agent TWICE (once as "onmessage", once as "reply_to_bot")
Solution: Debounce or deduplicate wake events:
var recentWake sync.Map
func (p *Plugin) wakeAgentWithReason(post *model.Post, reason string) error {
key := fmt.Sprintf("%s:%d", post.Id, time.Now().Unix()/5) // 5-second window
if _, exists := recentWake.LoadOrStore(key, true); exists {
p.API.LogDebug("Duplicate wake suppressed", "post_id", post.Id)
return nil
}
return p.performWake(post, reason)
}
6. Permission Matrix in Mattermost
Pitfall: Bot may not have permission to read parent post metadata.
Check:
$ openclaw plugin exec mattermost check-permissions
{
"can_read_channels": ["support-pipeline", "d rect-message-mike"],
"can_get_post": true,
"permission_check": "OK"
}
π Related Errors
Directly Related Issues
MM_PLUGIN_001β Parent post lookup returns 404: Channel may be private or bot lacks read permissions for parent message.MM_PLUGIN_002β WebSocket event received with nil post data: Indicates Mattermost server version incompatibility or malformed event.MM_PLUGIN_003β Bot user ID mismatch: The bot's Mattermost user ID changed (e.g., after re-installation), breaking authorship comparison.AGENT_LOOP_001β Infinite wake loop detected: Self-wake prevention not implemented or bypassed.THREAD_CTX_001β Root ID propagation failure: Thread context lost when agent responds, breaking conversation continuity.
Historical Context
- GitHub Issue #847 β "Mattermost plugin ignores threaded replies": Original report that identified the same root cause (2019, v1.8.x). Status: Closed as "Won't Fix" due to complexity. Resurfaced with agent-to-agent use case.
- GitHub Issue #1203 β "Telegram bridge handles reply context better than Mattermost": Comparison issue highlighting behavioral inconsistency between platforms.
- GitHub PR #1156 β "Add parent post resolution to Mattermost event handler": Proposed implementation (draft), blocked on API rate limit concerns.
Similar Patterns in Other Plugins
- Discord Plugin β Uses `message.reference` field similarly to Mattermost's `RootId`. Implementation can serve as reference:
discord/plugin.go:203-245 - Slack Plugin β Thread context in Slack includes `thread_ts` field. Current implementation only checks direct mentions.
- Matrix Plugin β Relies on `m.relates_to` metadata for thread detection. Partial implementation exists.
Known Limitations by Mattermost Version
| Version | Limitation | Workaround |
|---|---|---|
| 7.0-7.2 | WebSocket events may not include RootId for inline replies | Use ParentId fallback exclusively |
| 7.3-7.5 | Thread detection reliable | Standard implementation |
| 7.6+ | Full support | No known issues |
Error Code Reference
| Code | Severity | Description | Resolution |
|---|---|---|---|
MM_404_PARENT | WARN | Parent post not found | Verify bot has channel read access |
MM_RATE_LIMIT | WARN | API throttled during bulk lookup | Enable parent post caching |
MM_VERSION_INCOMPAT | ERROR | Unsupported Mattermost version | Upgrade to 7.3+ or disable thread detection |
MM_BOT_USER_CHANGE | ERROR | Bot user ID mismatch | Re-register bot in Mattermost |