Runtime Dify Kill Switch (Global and Per-Channel)¶
Before this change, the only way to stop Dify from replying was TELEGRAM_INBOUND_ENABLED, an env var far blunter than the problem it was used for: setting it drops /start, /help, payout flows, and contact-share handling along with the AI replies, and it has no WhatsApp equivalent at all [@design-doc]. Operators had no way to pause a misbehaving Dify integration without either redeploying code or breaking every other kind of inbound message handling. App\Settings\KillSwitchSettings fixes this with a runtime, admin-panel-managed toggle that pauses only the Dify hand-off, checked at one specific point in the message pipeline.
Context¶
The env-var kill switch and the problem it was being pressed into service for are different in kind. TELEGRAM_INBOUND_ENABLED=false (checked at the webhook, before the job even runs) is the right tool for "Telegram routing itself is broken, drop everything" — it's an emergency, deploy-time lever. It is the wrong tool for "Dify is producing bad replies right now, pause just that part for an hour," because it also kills slash commands and contact-share, which never touch Dify at all [@design-doc]. There was also no equivalent lever for WhatsApp. A finer, runtime-adjustable, per-channel control was needed underneath the existing emergency switch, not instead of it.
Decision¶
KillSwitchSettings, a spatie/laravel-settings class grouped under kill_switch, exposes three booleans: a global dify_enabled and per-channel dify_enabled_telegram / dify_enabled_whatsapp, all defaulting to true [@kill-switch-settings]. Its isDifyEnabledFor(ChannelTypeEnum $channel) method short-circuits to false if the global toggle is off; otherwise it matches on the channel, falling through to true for any channel not explicitly listed [@kill-switch-settings]:
return match ($channel) {
ChannelTypeEnum::Telegram => $this->dify_enabled_telegram,
ChannelTypeEnum::WhatsApp => $this->dify_enabled_whatsapp,
default => true,
};
That default => true arm is a deliberate fail-open choice: a future channel added to ChannelTypeEnum is unaffected by this kill switch until someone deliberately gives it its own toggle, rather than being silently dropped or silently left unprotected by an ambiguous default [@kill-switch-settings]. Toggles are edited from a Filament settings page (mirroring the existing settings-page pattern), so flipping one is a no-deploy operation.
The check itself lives in ProcessIncomingMessage::handle(), placed after the inbound message is stored and after the Message Received Mixpanel event fires, but before the call to DifyService::chat() [@process-incoming-message]:
if (! app(KillSwitchSettings::class)->isDifyEnabledFor($this->channel)) {
Log::info('dify.disabled_noop', [...]);
return;
}
That placement is the whole design: everything upstream of it — finding the user, creating the channel account and conversation, persisting the inbound message, firing analytics — runs unconditionally, so message ingestion and audit trail are unaffected by the switch. Only the AI hand-off and the reply it would have produced are skipped, silently — the user gets no reply and no "we're offline" message, and no buffered replay happens when the switch is flipped back on [@design-doc]. See the messaging pipeline for where this job sits in the larger inbound flow, and MCP servers and the Dify integration for how this pause point relates to the absence of any guardrail/observability layer in front of the Dify call itself.
Status¶
Approved and implemented.
Consequences¶
Pausing Dify is now a toggle, not a deploy: an operator can disable AI replies globally or for one channel while leaving /start, /payout, contact-share, and outbound alert sends fully functional. The two layers stack rather than replace each other — TELEGRAM_INBOUND_ENABLED=false still short-circuits earlier, at the webhook, and makes the settings check moot for that channel, so the settings layer only matters when the coarser env-var switch is left on [@design-doc]. The tradeoff of "silent drop, no buffered replay" is intentional: a message sent while Dify is paused is never automatically answered later, so an operator flipping this switch on for an extended period should expect a visible gap in AI responses rather than a backlog that clears itself. Adding kill-switch coverage for a channel beyond Telegram and WhatsApp requires a deliberate code change to the match arm — the fail-open default means an unlisted channel is not accidentally paused, but it also means it is not accidentally protected.