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 reply
  • on_flow_failed — swaps 👀, sends ephemeral error with actionable message
  • deliver_result — posts the agent's final response as a threaded reply
  • deliver_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:

  1. Pre-Duo checks (unchanged): no bot token → skip; no linked user → 🔒 + auth link; no feature flag → 🔒 + error; no license → 🔒 + error
  2. trigger_duo_flow (new): builds goal from thread context, builds callback_context, calls TriggerFlowService
  3. 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:

  1. Adding high-level helpers to Slack::API (the natural owner of "how to talk to Slack")
  2. Both AppMentionedService and the Slack adapter use these helpers
  3. 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 (via ensure_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 (from CallbackWorker)
  • 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_exception for 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 an invalid_auth error. This is expected — you're verifying the code path (which API calls are made, in what order), not actual Slack delivery. Focus on the INFO-level log messages and ignore the interleaved ERROR lines with invalid_auth.

Tests 3–5 will also log Slack API error when fetching thread at 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 ephemeral

The 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)
end

Check: 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 ephemeral

Note: 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 set

Check: user.default_duo_namespace should be nil. If it's set from a previous run, the update! 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 ephemeral

Note: The removing reaction eyes is a defensive no-op — 👀 was never added because the flow failed synchronously before on_flow_started. With a real Slack token this would return a no_reaction error (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_namespace should return the group. Verify with user.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 ephemeral

Note: Same as Test 3, the removing reaction eyes is 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."
end
send_slack_event "<@U_BOT_GDK> fix the CI pipeline"

Expected log (flow starts):

Duo Messaging: flow started     workflow=<id>
Slack API: adding reaction      reaction=eyes

Verify: 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_mark

Verify 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
Edited by Thomas Schmidt

Merge request reports

Loading