May 06, 2026 β€’ Version: 2026.4.26

Google Chat REACTION_ADDED/REACTION_REMOVED Webhook Events Silently Dropped

Google Chat webhook handler short-circuits on eventType !== 'MESSAGE', causing all reaction events to be silently discarded before reaching plugin handlers.

πŸ” Symptoms

Primary Manifestation

Reaction events from Google Chat are processed by the webhook endpoint but immediately discarded before reaching any plugin handler. Users adding emoji reactions to bot messages generate no observable activity.

Technical Evidence

Webhook endpoint receives but drops events:

// In monitor-webhook.ts lines 81-93 (current behavior)
if (eventType !== "MESSAGE") {
  // Event is silently ignored - no logging, no plugin dispatch
  return;
}

No reaction event routing in monitor.ts:

// In monitor.ts lines 58-59 (current behavior)
if (eventType !== "MESSAGE") {
  return; // Early return drops all non-message events
}

Observable Behavior

When a user adds a reaction to a bot message in a Google Chat space:

# No entry appears in logs for reaction events
tail -f /tmp/openclaw/openclaw-$(date +%Y%m%d).log | grep -i reaction
# Returns: (empty - no matches)

# Yet MESSAGE events flow correctly
tail -f /tmp/openclaw/openclaw-$(date +%Y%m%d).log | grep googlechat
# Shows: [googlechat] Processing MESSAGE event from space 'spaces/XXXXXX'
# Does NOT show any REACTION_* event processing

Missing Plugin Hook

No googlechat.onReaction equivalent exists:

// Attempting to register a reaction handler fails silently
agent.googlechat.onReaction?.((reaction) => {
  console.log('User reacted:', reaction.emoji);
});
// Result: No error, but handler never fires because hook doesn't exist

Google Cloud Console Confirmation

The Google Chat app may be correctly configured with reaction subscriptions:

# In Google Cloud Console > Chat API > Subscription Configuration:
# βœ“ MESSAGE
# βœ“ REACTION_ADDED  
# βœ“ REACTION_REMOVED
# 
# Yet only MESSAGE events trigger agent activity

🧠 Root Cause

Architectural Gap

The Google Chat extension implements an event-type whitelist pattern that exclusively permits MESSAGE events. This design decision predates reaction infrastructure and creates a silent-drop behavior for all other Google Chat event types.

Code Flow Analysis

Inbound Request Path:

1. HTTP POST received at /webhook/googlechat/{spaceId}
   ↓
2. monitor-webhook.ts:parseWebhookEvent() extracts eventType
   ↓
3. monitor-webhook.ts:81-93 β€” EVENT TYPE GATE
   if (eventType !== "MESSAGE") return;  ← SILENT DROP
   ↓
4. Event passed to monitor.ts:handleMessage()
   ↓
5. monitor.ts:58-59 β€” SECONDARY GATE (redundant for webhooks)
   if (eventType !== "MESSAGE") return;
   ↓
6. Plugin handlers receive normalized message (only)

Payload Structure Ignored

Google Chat sends structured reaction payloads that are never parsed:

// Example REACTION_ADDED webhook payload (never processed)
{
  "event": {
    "type": "REACTION_ADDED",
    "reaction": {
      "emoji": {
        "unicode": "πŸ‘"
      }
    },
    "message": {
      "name": "spaces/XXXXXX/messages/YYYYYY"
    },
    "user": {
      "name": "users/ZZZZZZ",
      "displayName": "Jane Developer"
    }
  }
}

Comparison with Discord Implementation

Discord’s extension correctly routes reaction events:

// extensions/discord/src/monitor.ts (reference implementation)
// Discord routes REACTION_ADDED/REMOVED to:
- discord.onReaction(handler) // Plugin hook
- Internal normalized reaction events
- Reaction-to-agent-turn pipeline (PR #60805)

Missing Components

The Google Chat extension lacks:

  • normalizeReactionEvent() β€” No function to convert Google Chat reaction payload to internal ReactionEvent schema
  • REACTION_ADDED/REACTION_REMOVED branching in monitor-webhook.ts
  • onReaction plugin hook registration in googlechat.d.ts
  • Reaction event logging for observability
  • Documentation of reaction subscription requirements

Independence from Outbound Scope Issue

This is explicitly NOT related to issue #9764 (outbound reaction creation). The inbound drop occurs regardless of OAuth scope configuration:

// Even with full user OAuth and chat.messages.reactions scope:
// The webhook still receives REACTION_ADDED payload
// But lines 81-93 still drop it before any downstream processing

πŸ› οΈ Step-by-Step Fix

Phase 1: Update monitor-webhook.ts Event Routing

File: extensions/googlechat/src/monitor-webhook.ts

Before (lines 81-93):

// Current code β€” drops all non-MESSAGE events
if (eventType !== "MESSAGE") {
  return;
}

After:

// Route reaction events to normalized handler
if (eventType === "REACTION_ADDED" || eventType === "REACTION_REMOVED") {
  const reactionEvent = normalizeReactionEvent(event, eventType);
  logger.debug(
    `GoogleChat reaction ${eventType}: user=${reactionEvent.userId} emoji=${reactionEvent.emoji} message=${reactionEvent.messageId}`
  );
  // Dispatch to reaction handlers (implemented in Phase 2)
  return; // Placeholder until handler chain is wired
}

if (eventType !== "MESSAGE") {
  logger.debug(`GoogleChat: unhandled event type '${eventType}', dropping`);
  return;
}

Phase 2: Add normalizeReactionEvent Function

File: extensions/googlechat/src/utils/reaction.ts (new file)

import type { ReactionEvent } from "@openclaw/core";

export function normalizeReactionEvent(
  event: GoogleChatEvent,
  eventType: "REACTION_ADDED" | "REACTION_REMOVED"
): ReactionEvent {
  const reaction = event.event?.reaction;
  const user = event.event?.user;
  const message = event.event?.message;

  return {
    type: eventType === "REACTION_ADDED" ? "reaction_added" : "reaction_removed",
    channelId: message?.name?.split("/")[1] ?? "", // spaces/XXXXX
    messageId: message?.name ?? "",
    userId: user?.name ?? "",
    userName: user?.displayName ?? "Unknown",
    emoji: reaction?.emoji?.unicode ?? reaction?.emoji?.customEmoji?.uid ?? "unknown",
    timestamp: new Date().toISOString(),
    raw: event,
  };
}

Phase 3: Extend Plugin Hook Interface

File: extensions/googlechat/src/googlechat.d.ts

Add to interface:

interface GoogleChatPlugin {
  onMessage?: (event: NormalizedMessage) => void | Promise;
  // ADD:
  onReaction?: (event: ReactionEvent) => void | Promise;
}

Phase 4: Wire Reaction Handler in monitor.ts

File: extensions/googlechat/src/monitor.ts

Add import:

import { normalizeReactionEvent } from "./utils/reaction";

Update event handling (after line 58-59):

// Handle reaction events
if (eventType === "REACTION_ADDED" || eventType === "REACTION_REMOVED") {
  const reactionEvent = normalizeReactionEvent(parsedBody, eventType);
  
  // Execute plugin reaction handlers
  for (const handler of reactionHandlers) {
    try {
      await handler(reactionEvent);
    } catch (err) {
      logger.error(`GoogleChat reaction handler error: ${err}`);
    }
  }
  return;
}

Phase 5: Export reactionHandlers Registration

File: extensions/googlechat/src/index.ts (or public API surface)

// Add to exports
export function onReaction(handler: (event: ReactionEvent) => void | Promise) {
  reactionHandlers.push(handler);
  return () => {
    const idx = reactionHandlers.indexOf(handler);
    if (idx >= 0) reactionHandlers.splice(idx, 1);
  };
}

Phase 6: Update Documentation

File: docs/channels/googlechat.md

Add new section:

## Reaction Events

Google Chat supports inbound reaction events when the Chat app is subscribed to `REACTION_ADDED` and `REACTION_REMOVED` event types.

### Enabling Reaction Subscriptions

In Google Cloud Console > Chat API > Subscription Configuration, ensure:
- `REACTION_ADDED` βœ“
- `REACTION_REMOVED` βœ“

### Plugin Handler

typescript
agent.googlechat.onReaction((reaction) => {
  if (reaction.type === "reaction_added") {
    console.log(`${reaction.userName} reacted with ${reaction.emoji}`);
  }
});

πŸ§ͺ Verification

Pre-Fix Baseline

Confirm the issue exists before applying fixes:

# 1. Check current code gates
grep -n "eventType !== \"MESSAGE\"" extensions/googlechat/src/monitor-webhook.ts
# Expected: line 81-93 (the problematic gate)

grep -n "eventType !== \"MESSAGE\"" extensions/googlechat/src/monitor.ts
# Expected: line 58-59 (secondary gate)

# 2. Verify no reaction handler exports
grep -n "onReaction" extensions/googlechat/src/index.ts
# Expected: (empty - hook doesn't exist yet)

Post-Fix Verification Steps

Step 1: Code Structure Verification

# Verify reaction normalization function exists
ls -la extensions/googlechat/src/utils/reaction.ts
# Expected: File exists

# Verify event routing branches added
grep -n "REACTION_ADDED\|REACTION_REMOVED" extensions/googlechat/src/monitor-webhook.ts
# Expected: New conditional branches present

# Verify hook interface extended
grep -n "onReaction" extensions/googlechat/src/googlechat.d.ts
# Expected: onReaction defined in interface

Step 2: Unit Test Validation

# Run Google Chat extension tests
cd extensions/googlechat && npm test

# Expected output:
# βœ“ normalizeReactionEvent transforms REACTION_ADDED correctly
# βœ“ normalizeReactionEvent transforms REACTION_REMOVED correctly  
# βœ“ Event router dispatches to reaction handler
# βœ“ Plugin onReaction hook fires on incoming reaction

Step 3: Integration Test with Mock Webhook

# Send mock REACTION_ADDED event to webhook endpoint
curl -X POST http://localhost:3000/webhook/googlechat/spaces/testSpace \
  -H "Content-Type: application/json" \
  -d '{
    "event": {
      "type": "REACTION_ADDED",
      "reaction": { "emoji": { "unicode": "πŸ‘" } },
      "message": { "name": "spaces/testSpace/messages/msg123" },
      "user": { "name": "users/user456", "displayName": "Test User" }
    }
  }'

# Verify log entry appears
tail -n 50 /tmp/openclaw/openclaw-$(date +%Y%m%d).log | grep -i "reaction"
# Expected: [googlechat] reaction REACTION_ADDED: user=users/user456 emoji=πŸ‘

Step 4: Plugin Handler Verification

# Create test plugin
cat > /tmp/test-gchat-reaction.js << 'EOF'
module.exports = (agent) => {
  agent.googlechat.onReaction((reaction) => {
    console.log("PLUGIN_REACTION:", JSON.stringify(reaction));
  });
};
EOF

# Restart openclaw with plugin loaded
openclaw --plugin /tmp/test-gchat-reaction.js

# Send reaction event
curl -X POST http://localhost:3000/webhook/googlechat/spaces/testSpace \
  -H "Content-Type: application/json" \
  -d '{"event":{"type":"REACTION_ADDED","reaction":{"emoji":{"unicode":"πŸ”₯"}},"message":{"name":"spaces/AAA/messages/BBB"},"user":{"name":"users/CCC","displayName":"DDD"}}}'

# Verify plugin output
tail -n 20 /tmp/openclaw/openclaw-$(date +%Y%m%d).log | grep "PLUGIN_REACTION"
# Expected: PLUGIN_REACTION: {"type":"reaction_added","emoji":"πŸ”₯",...}

Step 5: Google Chat App Configuration Verification

# In Google Cloud Console, verify subscription includes reactions:
# 1. Navigate to: APIs & Services > Enabled APIs > Google Chat API > Configuration
# 2. Scroll to "Subscription event types"
# 3. Confirm:
#    βœ“ MESSAGE (default)
#    βœ“ REACTION_ADDED  
#    βœ“ REACTION_REMOVED

# Test with actual Google Chat space:
# 1. Add bot to a space
# 2. Send a message via the bot
# 3. Have another user add reaction to that message
# 4. Verify reaction reaches plugin handler

⚠️ Common Pitfalls

1. Google Chat App Subscription Misconfiguration

Trap: The Chat app must explicitly subscribe to reaction events in Google Cloud Console. Default subscription settings only include MESSAGE.

Mitigation:

# Verify in Google Cloud Console:
# Chat API > Configuration > Subscription event types
# Must include both REACTION_ADDED and REACTION_REMOVED
# Simply having the webhook endpoint isn't sufficient

2. Space Type Incompatibility

Trap: Google Chat reactions only work in named spaces (not direct messages).

Mitigation:

# Reaction events are only delivered for messages in Rooms/Spaces
# DM conversations do not trigger reaction events
# Ensure testing occurs in a named space with multiple members

3. Bot Message Identification

Trap: Reaction events fire for ANY message reaction, not just bot messages. Plugins must filter.

Mitigation:

agent.googlechat.onReaction((reaction) => {
  // Only handle reactions to bot messages
  if (!reaction.messageId.includes(botMessagePrefix)) {
    return; // Ignore reactions to other users' messages
  }
  // Process reaction...
});

4. Emoji Format Variance

Trap: Google Chat may send reactions as Unicode emoji OR custom emoji objects with different structures.

Mitigation:

// Handle both formats in normalizeReactionEvent:
const emoji = reaction.emoji?.unicode 
  ?? reaction.emoji?.customEmoji?.uid 
  ?? "unknown";

5. Race Condition with Message Deletion

Trap: Users may remove reactions, and the original message may be deleted before REACTION_REMOVED event arrives.

Mitigation:

agent.googlechat.onReaction(async (reaction) => {
  if (reaction.type === "reaction_removed") {
    // messageId may be orphaned if original message deleted
    const messageExists = await fetchMessage(reaction.messageId).catch(() => null);
    if (!messageExists) {
      logger.debug("Reaction removed from deleted message, ignoring");
      return;
    }
  }
});

6. Duplicate Event Delivery

Trap: Google Chat may deliver duplicate reaction events during high load or network issues.

Mitigation:

// Implement idempotency check
const processedEvents = new Set();
agent.googlechat.onReaction((reaction) => {
  const eventKey = `${reaction.messageId}:${reaction.userId}:${reaction.emoji}:${reaction.type}`;
  if (processedEvents.has(eventKey)) return;
  processedEvents.add(eventKey);
  setTimeout(() => processedEvents.delete(eventKey), 60000); // 60s dedup window
});

7. Version Compatibility

Trap: This fix requires openclaw 2026.5+ (or corresponding version containing the changes). Earlier versions lack the reaction normalization schema.

Mitigation:

# Check openclaw version
openclaw --version
# Must be >= version containing reaction infrastructure

# For older versions, the onReaction hook will be undefined
if (agent.googlechat.onReaction) {
  agent.googlechat.onReaction(handler);
} else {
  console.warn("Reactions not supported in this openclaw version");
}

Logically Connected Issues

  • #60805 β€” Discord reaction event forwarding to plugin handlers (merged, reference implementation for Google Chat parity)
  • #56653 β€” Slack Socket Mode reaction event delivery tracking (open, similar gap for Slack channel)
  • #52658 β€” Discord reaction infrastructure implementation (earlier tracking issue)
  • #17840 β€” Generic feature flag for opt-in reaction-triggered agent turns
  • #9764 β€” Add user OAuth support for reactions and media uploads (outbound reactions, different scope β€” tracked separately)
Error CodeDescriptionConnection
GCH_EVENT_DROPPEDInternal: Non-MESSAGE event discarded at webhook gateDirect symptom of this issue
GCH_NO_HANDLERPlugin attempted to register onReaction but hook doesn’t existManifests pre-fix
GCH_PAYLOAD_UNPARSEABLEReaction payload structure changed, normalization failsRegression risk
DISCORD_REACTION_OKDiscord reaction handling succeedsReference for expected behavior

Cross-Channel Reference

The reaction infrastructure follows the pattern established in:

extensions/discord/src/monitor.ts       // βœ“ Implemented
extensions/telegram/src/reactions.ts     // βœ“ Implemented  
extensions/feishu/src/reaction.ts        // βœ“ Implemented
extensions/matrix/src/reaction-handler.ts // βœ“ Implemented
extensions/googlechat/src/monitor.ts    // βœ— Missing (this issue)

Documentation Dependencies

  • `docs/channels/googlechat.md` β€” Needs reaction event documentation (Section 6 of fix)
  • `docs/plugins/hooks.md` β€” Needs `googlechat.onReaction` entry
  • `docs/events/reaction-events.md` β€” General reaction event schema (reference for all channels)

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.