Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion backend/danswer/connectors/slack/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from danswer.connectors.slack.utils import make_slack_api_call_logged
from danswer.connectors.slack.utils import make_slack_api_call_paginated
from danswer.connectors.slack.utils import make_slack_api_rate_limited
from danswer.connectors.slack.utils import resolve_workspace_subdomain
from danswer.connectors.slack.utils import SlackTextCleaner
from danswer.utils.logger import setup_logger

Expand Down Expand Up @@ -323,6 +324,11 @@ def get_all_docs(
client=client, channel=channel, oldest=oldest, latest=latest
)

# Resolve the channel's true workspace subdomain (Grid-safe) lazily on
# the first message, then reuse it for every thread in the channel — one
# chat.getPermalink call per channel rather than per message.
channel_workspace: str | None = None

seen_thread_ts: set[str] = set()
for message_batch in channel_message_batches:
for message in message_batch:
Expand All @@ -344,9 +350,16 @@ def get_all_docs(
filtered_thread = [message]

if filtered_thread:
if channel_workspace is None:
channel_workspace = resolve_workspace_subdomain(
client=client,
channel_id=channel["id"],
message_ts=filtered_thread[0]["ts"],
fallback=workspace,
)
channel_docs += 1
yield thread_to_doc(
workspace=workspace,
workspace=channel_workspace,
channel=channel,
thread=filtered_thread,
slack_cleaner=slack_cleaner,
Expand Down
78 changes: 78 additions & 0 deletions backend/danswer/connectors/slack/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from functools import wraps
from typing import Any
from typing import cast
from urllib.parse import urlparse

from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
Expand All @@ -18,6 +19,23 @@
# number of messages we request per page when fetching paginated slack messages
_SLACK_LIMIT = 900

# Mis-stored Slack workspace subdomains -> their correct URL subdomain.
# Historically some connectors were configured with the workspace *display name*
# ("Product") instead of the URL subdomain ("uipath-product"), so their stored
# permalinks point at the dead host `product.slack.com`. Keys are matched case-
# and whitespace-insensitively, so this covers both "Product" and "Product ".
# This is an explicit allow-map on purpose: only listed subdomains are rewritten,
# so genuinely different workspaces (uipath-customer-ops, uipath-marketing, ...)
# and links already on a correct host are left untouched. Add an entry here if a
# new mis-configured workspace surfaces.
_SLACK_SUBDOMAIN_FIXES = {
"product": "uipath-product",
}

# Captures the subdomain in the `//<sub>.slack.com` portion of a permalink.
# `[^/]*?` is non-greedy and tolerates a stray space ("Product .slack.com").
_SLACK_HOST_RE = re.compile(r"(//)([^/]*?)(\.slack\.com)", re.IGNORECASE)


def get_message_link(
event: dict[str, Any], workspace: str, channel_id: str | None = None
Expand All @@ -34,6 +52,66 @@ def get_message_link(
)


def normalize_slack_link(url: str) -> str:
"""Rewrite a mis-stored Slack workspace subdomain to the canonical one.

Existing indexed docs carry links built from a mis-configured workspace
("Product" -> dead `product.slack.com`). Retrieval reads every citation
link back through here, so chat / search / Slack-bot citations all resolve
to the real workspace without re-indexing or a data migration. Only the
subdomains in `_SLACK_SUBDOMAIN_FIXES` are rewritten; a link on the canonical
host, or on a genuinely different workspace, is returned unchanged."""
if not url or ".slack.com" not in url.lower():
return url

def _fix(match: "re.Match[str]") -> str:
subdomain = match.group(2).strip().lower()
canonical = _SLACK_SUBDOMAIN_FIXES.get(subdomain)
if canonical is not None:
return f"{match.group(1)}{canonical}{match.group(3)}"
return match.group(0)

return _SLACK_HOST_RE.sub(_fix, url, count=1)


def resolve_workspace_subdomain(
client: WebClient, channel_id: str, message_ts: str, fallback: str
) -> str:
"""Ask Slack for the authoritative workspace subdomain of a channel.

`chat.getPermalink` returns the real permalink for a message, with the
correct `<sub>.slack.com` host even under Enterprise Grid (where a single
bot can see channels that live in different workspaces, each with its own
URL). We resolve this ONCE per channel and reuse it, so the connector no
longer depends on a hand-typed `workspace` config being right. Falls back to
`fallback` (the configured workspace) if the call fails."""
try:
# Same rate-limit + logging wrapping as every other Slack call in the
# connector, so a 429 retries (with Retry-After) instead of falling back.
resp = make_slack_api_rate_limited(
make_slack_api_call_logged(client.chat_getPermalink)
)(channel=channel_id, message_ts=message_ts)
permalink = cast(str, resp.get("permalink") or "")
host = urlparse(permalink).netloc.lower()
if host.endswith(".slack.com"):
subdomain = host[: -len(".slack.com")]
if subdomain:
return subdomain
except SlackApiError as e:
logger.warning(
"chat.getPermalink failed for channel %s (%s); "
"falling back to configured workspace '%s'",
channel_id,
e.response.get("error"),
fallback,
)
except Exception as e:
logger.warning(
"could not resolve workspace subdomain for channel %s: %s", channel_id, e
)
return fallback


def make_slack_api_call_logged(
call: Callable[..., SlackResponse],
) -> Callable[..., SlackResponse]:
Expand Down
7 changes: 6 additions & 1 deletion backend/danswer/document_index/vespa/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
from danswer.connectors.cross_connector_utils.miscellaneous_utils import (
get_experts_stores_representations,
)
from danswer.connectors.slack.utils import normalize_slack_link
from danswer.document_index.document_index_utils import get_uuid_from_chunk
from danswer.document_index.interfaces import DocumentIndex
from danswer.document_index.interfaces import DocumentInsertionRecord
Expand Down Expand Up @@ -681,8 +682,12 @@ def _vespa_hit_to_inference_chunk(hit: dict[str, Any]) -> InferenceChunk:
source_links_dict_unprocessed = (
json.loads(source_links) if isinstance(source_links, str) else source_links
)
# Slack: historical docs were indexed with a mis-configured workspace, so
# their permalinks point at a dead host. Rewrite to the canonical workspace
# at read time so every citation (chat / search / Slack bot) resolves.
is_slack = str(fields.get(SOURCE_TYPE, "")).lower() == "slack"
source_links_dict = {
int(k): v
int(k): (normalize_slack_link(v) if is_slack else v)
for k, v in cast(dict[str, str], source_links_dict_unprocessed).items()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import pytest
from slack_sdk.errors import SlackApiError

from danswer.connectors.slack.utils import normalize_slack_link
from danswer.connectors.slack.utils import resolve_workspace_subdomain


class _FakeResp(dict):
"""Minimal stand-in for slack_sdk's SlackResponse (dict-like + validate())."""

def validate(self) -> "_FakeResp":
return self


class _FakeClient:
def __init__(self, permalink: str | None = None, error: str | None = None):
self._permalink = permalink
self._error = error

def chat_getPermalink(self, channel: str, message_ts: str) -> _FakeResp:
if self._error is not None:
raise SlackApiError(self._error, {"ok": False, "error": self._error})
return _FakeResp({"ok": True, "permalink": self._permalink})


@pytest.mark.parametrize(
"raw, expected",
[
# The bug: workspace display name "Product" -> dead product.slack.com.
(
"https://Product.slack.com/archives/C02EF2C5KS8/p1712595481616829",
"https://uipath-product.slack.com/archives/C02EF2C5KS8/p1712595481616829",
),
# Trailing space variant seen in the data ("Product ").
(
"https://Product .slack.com/archives/C02/p1?thread_ts=1712595481.616829",
"https://uipath-product.slack.com/archives/C02/p1?thread_ts=1712595481.616829",
),
# Case-insensitive match.
(
"https://product.slack.com/archives/C02/p1",
"https://uipath-product.slack.com/archives/C02/p1",
),
# Idempotent: already-canonical link is untouched.
(
"https://uipath-product.slack.com/archives/C02/p1",
"https://uipath-product.slack.com/archives/C02/p1",
),
# Genuinely different workspaces must NOT be rewritten.
(
"https://uipath-customer-ops.slack.com/archives/C99/p2",
"https://uipath-customer-ops.slack.com/archives/C99/p2",
),
(
"https://uipath-marketing.slack.com/archives/C99/p2",
"https://uipath-marketing.slack.com/archives/C99/p2",
),
# Non-Slack links pass through unchanged (even if a path says "product").
(
"https://docs.uipath.com/product/latest",
"https://docs.uipath.com/product/latest",
),
# Only the host is rewritten, not a matching path segment.
(
"https://Product.slack.com/archives/product/p1",
"https://uipath-product.slack.com/archives/product/p1",
),
],
)
def test_normalize_slack_link(raw: str, expected: str) -> None:
assert normalize_slack_link(raw) == expected


def test_normalize_slack_link_empty() -> None:
assert normalize_slack_link("") == ""


def test_resolve_workspace_subdomain_from_permalink() -> None:
client = _FakeClient(
permalink="https://uipath-product.slack.com/archives/C02/p1712595481616829"
)
assert (
resolve_workspace_subdomain(client, "C02", "1712595481.616829", fallback="X") # type: ignore[arg-type]
== "uipath-product"
)


def test_resolve_workspace_subdomain_other_workspace() -> None:
client = _FakeClient(
permalink="https://uipath-customer-ops.slack.com/archives/C99/p1"
)
assert (
resolve_workspace_subdomain(client, "C99", "1.1", fallback="X") # type: ignore[arg-type]
== "uipath-customer-ops"
)


@pytest.mark.parametrize(
"client",
[
_FakeClient(error="channel_not_found"), # API error -> fallback
_FakeClient(permalink=""), # empty permalink -> fallback
_FakeClient(permalink="https://example.com/not-slack"), # non-slack -> fallback
],
)
def test_resolve_workspace_subdomain_falls_back(client: _FakeClient) -> None:
assert (
resolve_workspace_subdomain(client, "C1", "1.1", fallback="uipath-product") # type: ignore[arg-type]
== "uipath-product"
)
2 changes: 1 addition & 1 deletion k8s/overlays/prod/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ namespace: darwin
images:
- name: danswer-backend
newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-backend
newTag: vha-216
newTag: vha-218
- name: danswer-web-server
newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-web-server
newTag: vha-116
Expand Down
Loading