Duo Messaging: Slack adapter + AppMentionedService wiring
Problem
The Duo Messaging Service enables users to interact with Duo from external messaging services. Users @mention Duo in Slack, give it a task, and Duo works on it asynchronously using the existing Flows API (CI pipeline) infrastructure.
!232332 (merged) added the workspace project foundation. !232343 (merged) added the core orchestration layer (TriggerFlowService, Adapters::Base, CallbackWorker). This MR adds the Slack adapter and wires AppMentionedService to trigger real Duo Agent flows instead of the previous stub response.
Where this fits
This is the PoC MR broken into reviewable pieces:
| # | Issue | MR | Description |
|---|---|---|---|
| 1 | #597569 (closed) | !232332 (merged) |
Workspace project service |
| 2 | #597570 (closed) | !232343 (merged) |
Orchestrator, adapter base, callback infrastructure |
| 3 | #597571 (closed) | This MR | Slack adapter + AppMentionedService wiring |
| 4 | #597573 | — | User-facing documentation |
What does this MR do?
Slack::API — high-level helpers
Adds convenience methods to the existing Slack API client so callers don't need to construct raw API payloads:
add_reaction(channel:, name:, timestamp:)remove_reaction(channel:, name:, timestamp:)post_ephemeral(channel:, user:, text:)post_message(channel:, text:, thread_ts:)
Each method includes structured logging (Slack API: adding reaction, etc.) via Gitlab::IntegrationsLogger, providing observability for all Slack API calls across the application.
Ai::Messaging::Adapters::Slack
Concrete Slack adapter implementing the Adapters::Base interface from !232343 (merged):
on_flow_started— adds👀 reaction (flow is being processed)on_flow_completed— swaps👀 →✅ , posts agent response as threaded replyon_flow_failed— swaps👀 →❌ , sends ephemeral error with actionable messagedeliver_result— posts the agent's final response as a threaded replydeliver_error— posts an ephemeral error message visible only to the requesting user
Error messages are mapped from error symbols (:namespace_not_configured, :flow_not_enabled, etc.) to user-friendly text with guidance on how to resolve the issue.
All adapter methods that call the Slack API are wrapped in rescue StandardError with Gitlab::ErrorTracking.track_exception. This prevents Slack API failures (network errors, invalid tokens, etc.) from bubbling up to the caller — the adapter is a best-effort delivery mechanism, not a transactional one.
Uses the Slack::API high-level helpers — no raw post('reactions.add', ...) calls.
AppMentionedService rewrite
Replaces the previous WIP stub ("Thanks for the mention! I'm not ready...") with the real flow:
- Pre-Duo checks (unchanged): no bot token → skip; no linked user →
🔒 + auth link; no feature flag →🔒 + error; no license →🔒 + error trigger_duo_flow(new): builds goal from thread context, buildscallback_context, callsTriggerFlowService- Delegates to adapter: on success →
adapter.on_flow_started; on failure →adapter.on_flow_failed
The pre-Duo checks use Slack::API helpers directly (they're Slack integration concerns). The flow lifecycle uses the adapter (Duo Messaging concern). This keeps the responsibilities cleanly separated.
Goal building with thread context
The goal sent to the Duo agent is built from the full Slack thread context, not just the @mention message. build_goal fetches the thread via conversations.replies, resolves Slack user IDs to GitLab usernames via ChatName lookups, and assembles a structured prompt:
<slack_formatting>
Format your responses using Slack mrkdwn syntax:
- Use *bold* (not **bold**), _italic_ (not *italic*)
- ...
</slack_formatting>
<participants>
- Slack: U0001 | GitLab: @alice
- Slack: U0002
</participants>
<conversation>
<message author="U0001" gitlab="@alice">
Can someone help fix the CI pipeline?
</message>
<message author="U_BOT" gitlab="@duo-bot">
@GitLab fix the CI pipeline
</message>
</conversation>This gives the agent full conversational context, participant identities (with GitLab usernames where linked), and explicit Slack formatting instructions so responses render correctly in Slack.
Also refactored add_reaction and post_ephemeral to delegate to Slack::API helpers, and removed the old post_thread_reply method (now handled by the adapter via deliver_result).
Minor fix: renamed reason: → failure_reason: in a log call to follow LabKit field naming conventions.
CallbackWorker — adapter registration
Registers 'slack' => Ai::Messaging::Adapters::Slack in ADAPTER_REGISTRY, enabling the async callback path: when a workflow finishes, CallbackWorker resolves the Slack adapter from the messaging_callback_context and delivers the result.
End-to-end flow
Slack @mention → AppMentionedService
→ pre-Duo checks (auth, feature flag, license)
→ trigger_duo_flow:
→ TriggerFlowService.execute (from !232343)
→ on success: adapter.on_flow_started → 👀 reaction
→ on failure: adapter.on_flow_failed → ❌ reaction + ephemeral error
...workflow runs asynchronously as CI pipeline...
WorkloadFinishedEvent → CallbackWorker (from !232343)
→ resolves Slack adapter from callback_context
→ extracts agent response from workflow checkpoints
→ adapter.deliver_result → threaded reply
→ adapter.on_flow_completed → 👀 → ✅Design decisions
Slack::API helpers vs adapter-level Slack communication
The PoC had duplicated Slack API calls: AppMentionedService had its own add_reaction/post_ephemeral methods, and the adapter also made raw slack_api.post(...) calls. We consolidated by:
- Adding high-level helpers to
Slack::API(the natural owner of "how to talk to Slack") - Both
AppMentionedServiceand the Slack adapter use these helpers - Logging lives in
Slack::API— every Slack call is logged once, regardless of caller
Pre-Duo checks stay in AppMentionedService
The adapter handles Duo flow lifecycle (on_flow_started, on_flow_failed, etc.). The pre-Duo checks (no linked user, feature flag off, no license) are Slack integration concerns that happen before Duo is even involved:
- No linked user →
🔒 reaction + OAuth authorization link (viaensure_user_linked, which generates a dynamic auth URL) - Feature flag off →
🔒 reaction + "You do not have access to this feature yet." - No license →
🔒 reaction + "This feature requires GitLab Duo Agent Platform."
These use Slack::API helpers directly. We considered routing them through the adapter (via on_access_denied), but the ensure_user_linked flow generates a dynamic OAuth URL that doesn't fit the adapter's canned error message pattern. Keeping them in AppMentionedService avoids forced abstractions.
🔒 vs ❌ reactions
🔒 (lock) — access/auth issue (no linked user, feature flag off, no license). The user needs to do something outside the current context.❌ (x) — flow failure (no namespace, flow not enabled, execution error). The setup is incomplete but addressable.👀 (eyes) — flow is in progress✅ (white_check_mark) — flow completed successfully
Best-effort Slack delivery
All Slack API calls in the adapter are wrapped in rescue StandardError. The reasoning:
- The adapter runs both synchronously (from
AppMentionedService) and asynchronously (fromCallbackWorker) - A Slack API failure (network timeout, revoked token, rate limit) should not prevent the Duo workflow from starting or cause the callback worker to retry
- Reactions and ephemeral messages are UX polish, not critical data — a missing
👀 emoji is acceptable - Errors are tracked via
Gitlab::ErrorTracking.track_exceptionfor observability
How to test locally (GDK)
Simulates Slack app_mention events via curl and observes results in integrations_json.log. No real Slack workspace needed.
Note: Since we use a fake bot token (
xoxb-fake-token-for-gdk-testing), every Slack API call will return aninvalid_autherror. This is expected — you're verifying the code path (which API calls are made, in what order), not actual Slack delivery. Focus on theINFO-level log messages and ignore the interleavedERRORlines withinvalid_auth.Tests 3–5 will also log
Slack API error when fetching threadat the start — this is the thread context fetch failing with the fake token. The code falls back gracefully and continues.
Setup
You need two terminals: rails console and bash terminal.
Rails console — seed base data:
user = User.find_by(username: 'root')
# Signing secret
ApplicationSetting.current.update_columns(
encrypted_slack_app_signing_secret: nil,
encrypted_slack_app_signing_secret_iv: nil
)
ApplicationSetting.current.update!(slack_app_signing_secret: 'gdk-test-signing-secret')
# Slack integration
project = user.projects.first || Project.first
integration = Integrations::GitlabSlackApplication.find_or_initialize_by(project: project)
integration.update!(active: true)
si = SlackIntegration.find_or_initialize_by(team_id: 'T_GDK_TEST')
si.update!(
integration: integration, project: project, team_name: 'GDK Test Workspace',
user_id: 'U_INSTALLER', alias: 'gdk-test', bot_user_id: 'U_BOT_GDK',
bot_access_token: 'xoxb-fake-token-for-gdk-testing'
)
puts "✅ Base setup complete"Bash terminal — paste the helper function and start the log tail:
send_slack_event() {
local text="${1:-<@U_BOT_GDK> fix the CI pipeline}"
local slack_user="${2:-U_GDK_USER}"
local GDK_URL="http://gdk.test:3000"
local SIGNING_SECRET="gdk-test-signing-secret"
local TS="$(date +%s).000001"
local BODY='{"type":"event_callback","team_id":"T_GDK_TEST","event_id":"Ev_'"$(date +%s)"'","event":{"type":"app_mention","user":"'"${slack_user}"'","channel":"C_TEST","ts":"'"${TS}"'","text":"'"${text}"'"}}'
local TIMESTAMP=$(date +%s)
local SIGNATURE="v0=$(printf '%s' "v0:${TIMESTAMP}:${BODY}" | openssl dgst -sha256 -hmac "${SIGNING_SECRET}" | awk '{print $2}')"
echo "📤 Sending: \"${text}\" as ${slack_user}"
curl -s -w " HTTP %{http_code}\n" \
-X POST "${GDK_URL}/api/v4/integrations/slack/events" \
-H "Content-Type: application/json" \
-H "X-Slack-Request-Timestamp: ${TIMESTAMP}" \
-H "X-Slack-Signature: ${SIGNATURE}" \
-d "${BODY}"
}# In a separate pane or after sending events:
tail -f log/integrations_json.log | grep --line-buffered 'Slack API\|Duo Messaging' | ruby -rjson -e '
ARGF.each_line do |l|
j = JSON.parse(l) rescue next
parts = [j["time"], j["message"]]
parts << "reaction=#{j["reaction"]}" if j["reaction"]
parts << "reason=#{j["reason"]}" if j["reason"]
parts << "error=#{j["error_message"]}" if j["error_message"]
parts << "workflow=#{j["workflow_id"]}" if j["workflow_id"]
puts parts.join(" ")
end
'You can also check current state anytime:
user = User.find_by(username: 'root')
puts "ChatName: #{ChatName.find_by(team_id: 'T_GDK_TEST', chat_id: 'U_GDK_USER').present?}"
puts "Feature flag: #{Feature.enabled?(:slack_duo_agent, user)}"
puts "Namespace: #{user.default_duo_namespace&.full_path || 'nil'}"
puts "Flows: #{Ai::Catalog::EnabledFoundationalFlow.where(namespace: user.default_duo_namespace).count}"Reset (if re-running tests)
If you've run these tests before, reset to a clean state first:
user = User.find_by(username: 'root')
# Remove ChatName link
ChatName.find_by(team_id: 'T_GDK_TEST', chat_id: 'U_GDK_USER')&.destroy
# Disable feature flag
Feature.disable(:slack_duo_agent, user)
# Clear namespace and flows
if (ns = user.default_duo_namespace)
Ai::Catalog::EnabledFoundationalFlow.where(namespace: ns).delete_all
ns.namespace_settings.update!(duo_foundational_flows_enabled: false)
end
user.user_preference.update!(duo_default_namespace_id: nil)
puts "✅ Reset complete"Test scenarios
Run these in order — each step builds on the previous.
Test 1: Slack user not linked → 🔒
Check: ChatName.find_by(team_id: 'T_GDK_TEST', chat_id: 'U_GDK_USER') should be nil.
If it exists, delete it:
ChatName.find_by(team_id: 'T_GDK_TEST', chat_id: 'U_GDK_USER').destroy
send_slack_event "<@U_BOT_GDK> hello"Expected log:
Slack API: adding reaction reaction=lock
Slack API: posting ephemeralThe ephemeral message contains an OAuth authorization link for the user to link their Slack and GitLab accounts.
Test 2: User linked, feature flag off → 🔒
Do (rails console):
user = User.find_by(username: 'root')
ChatName.find_or_initialize_by(team_id: 'T_GDK_TEST', chat_id: 'U_GDK_USER').tap do |cn|
cn.update!(user: user, team_domain: 'gdk-test', chat_name: user.username)
endCheck: Feature.enabled?(:slack_duo_agent, user) should be false.
If it's
true, disable it:Feature.disable(:slack_duo_agent, user)
send_slack_event "<@U_BOT_GDK> hello"Expected log:
Slack API: adding reaction reaction=lock
Slack API: posting ephemeralNote: The log output is identical to Test 1. The difference is in the ephemeral message content — Test 1 sends an OAuth link, Test 2 sends "You do not have access to this feature yet." With a real Slack token you'd see the different messages in the channel.
Test 3: Feature flag on, no default namespace → ❌
Do (rails console):
user = User.find_by(username: 'root')
Feature.enable(:slack_duo_agent, user)
user.user_preference.update!(duo_default_namespace_id: nil) # ensure no namespace is setCheck:
user.default_duo_namespaceshould benil. If it's set from a previous run, theupdate!above clears it.
send_slack_event "<@U_BOT_GDK> fix the pipeline"Expected log:
Duo Messaging: flow failed reason=namespace_not_configured error=No default Duo namespace configured
Slack API: removing reaction reaction=eyes
Slack API: adding reaction reaction=x
Slack API: posting ephemeralNote: The
removing reaction eyesis a defensive no-op —👀 was never added because the flow failed synchronously beforeon_flow_started. With a real Slack token this would return ano_reactionerror (harmlessly caught).
Test 4: Namespace set, flow not enabled → ❌
Do (rails console):
user = User.find_by(username: 'root')
group = user.groups.first # use a top-level group (TriggerFlowService resolves to root_ancestor)
puts "Using group: #{group.full_path} (root_ancestor: #{group.root_ancestor.full_path})"
user.user_preference.update!(duo_default_namespace: group)
# Ensure flows are disabled for this test
Ai::Catalog::EnabledFoundationalFlow.where(namespace: group).delete_all
group.namespace_settings.update!(duo_foundational_flows_enabled: false)Check:
user.default_duo_namespaceshould return the group. Verify withuser.reload.default_duo_namespace&.full_path.
send_slack_event "<@U_BOT_GDK> fix the pipeline"Expected log:
Duo Messaging: flow failed reason=flow_not_enabled error=The developer/v1 flow is not enabled...
Slack API: removing reaction reaction=eyes
Slack API: adding reaction reaction=x
Slack API: posting ephemeralNote: Same as Test 3, the
removing reaction eyesis a defensive no-op.
Test 5: Everything enabled → ✅
Do (rails console):
user = User.find_by(username: 'root')
group = user.default_duo_namespace
group.namespace_settings.update!(duo_foundational_flows_enabled: true)
catalog_item = Ai::Catalog::Item.with_foundational_flow_reference('developer/v1').first
if catalog_item
Ai::Catalog::EnabledFoundationalFlow.find_or_create_by!(namespace: group, catalog_item: catalog_item)
puts "✅ developer/v1 enabled for #{group.full_path}"
else
puts "❌ No developer/v1 catalog item found. Set up Duo Agent Platform first."
endsend_slack_event "<@U_BOT_GDK> fix the CI pipeline"Expected log (flow starts):
Duo Messaging: flow started workflow=<id>
Slack API: adding reaction reaction=eyesVerify: A new "Duo Workspace / Developer/v1" pipeline should appear in the workspace project's CI/CD → Pipelines.
Expected log (when flow completes):
Slack API: posting message
Slack API: removing reaction reaction=eyes
Slack API: adding reaction reaction=white_check_markVerify in rails console:
w = Ai::DuoWorkflows::Workflow.order(created_at: :desc).first
puts w.goal
puts w.messaging_callback_context
# Should contain: {"adapter"=>"slack", "team_id"=>"T_GDK_TEST", "channel_id"=>"C_TEST", ...}Test summary
| Test | What changed | Expected log |
|---|---|---|
| 1 | Nothing (no ChatName) | Slack API: reaction=lock + ephemeral |
| 2 | + ChatName | Slack API: reaction=lock + ephemeral |
| 3 | + Feature flag | Duo Messaging: flow failed (namespace_not_configured) + Slack API: reaction=x |
| 4 | + Namespace | Duo Messaging: flow failed (flow_not_enabled) + Slack API: reaction=x |
| 5 | + developer/v1 | Duo Messaging: flow started + Slack API: reaction=eyes → pipeline → reaction=white_check_mark |
Related
- Issue: #597571 (closed)
- Parent: #590434
- Stacked on: !232343 (merged)
- PoC: !231853 (closed)
- ADR: gitlab-com/content-sites/handbook!19020 (merged)