From e95e12d60e5fa7b43a1a07909816693c6810be82 Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Fri, 17 Jul 2026 15:26:52 +0530 Subject: [PATCH 1/4] fix(slack): resolve dead product.slack.com citation links Slack docs were indexed with the workspace stored as its display name ("Product", plus a "Product " space variant), so every citation permalink pointed at the dead host product.slack.com. ~320k docs (913 channels) affected; all verified to genuinely live in the uipath-product workspace. Two layers, no re-index / data migration required: - Read-time: normalize_slack_link() rewrites the known-bad "product" subdomain to "uipath-product" via an explicit allow-map (case/space-insensitive), applied in _vespa_hit_to_inference_chunk. Every citation (chat, search, Slack bot) derives its link from source_links, so one choke point fixes all three. Genuinely different workspaces (uipath-customer-ops, uipath-marketing, ...) are left untouched. - Index-time: get_all_docs now resolves each channel's true workspace subdomain once via chat.getPermalink (Grid-correct), so new scrapes are right regardless of the configured workspace; the configured value is only a fallback. Verified on prod (read-only): normalized links matched Slack's authoritative chat.getPermalink for 40/40 sampled docs, and the real keyword_retrieval path returned 10/10 uipath-product links. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/danswer/connectors/slack/connector.py | 15 ++- backend/danswer/connectors/slack/utils.py | 74 +++++++++++++ backend/danswer/document_index/vespa/index.py | 7 +- .../slack/test_normalize_slack_link.py | 103 ++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py diff --git a/backend/danswer/connectors/slack/connector.py b/backend/danswer/connectors/slack/connector.py index 76d4dcdea80..d1348b9e7c2 100644 --- a/backend/danswer/connectors/slack/connector.py +++ b/backend/danswer/connectors/slack/connector.py @@ -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 @@ -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: @@ -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, diff --git a/backend/danswer/connectors/slack/utils.py b/backend/danswer/connectors/slack/utils.py index 48a591f2aca..27e1cca19ed 100644 --- a/backend/danswer/connectors/slack/utils.py +++ b/backend/danswer/connectors/slack/utils.py @@ -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 @@ -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 `//.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 @@ -34,6 +52,62 @@ 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 `.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: + resp = 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]: diff --git a/backend/danswer/document_index/vespa/index.py b/backend/danswer/document_index/vespa/index.py index a07b292ca94..ac868bfab36 100644 --- a/backend/danswer/document_index/vespa/index.py +++ b/backend/danswer/document_index/vespa/index.py @@ -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 @@ -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() } diff --git a/backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py b/backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py new file mode 100644 index 00000000000..29be426246a --- /dev/null +++ b/backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py @@ -0,0 +1,103 @@ +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 _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) -> dict: + if self._error is not None: + raise SlackApiError(self._error, {"ok": False, "error": self._error}) + return {"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" + ) From 1296a78e2e2fe9a1e7768feffccb3864b854bead Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Fri, 17 Jul 2026 15:49:38 +0530 Subject: [PATCH 2/4] k8s(prod): bump backend=vha-217 (slack citation link fix) Co-Authored-By: Claude Opus 4.8 (1M context) --- k8s/overlays/prod/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml index d04b06b4f26..7d6a5140fae 100644 --- a/k8s/overlays/prod/kustomization.yaml +++ b/k8s/overlays/prod/kustomization.yaml @@ -30,7 +30,7 @@ namespace: darwin images: - name: danswer-backend newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-backend - newTag: vha-216 + newTag: vha-217 - name: danswer-web-server newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-web-server newTag: vha-116 From 86041d9bf6eab1ce7f9416f07afd574c47165096 Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Fri, 17 Jul 2026 15:53:11 +0530 Subject: [PATCH 3/4] refactor(slack): rate-limit chat.getPermalink like every other slack call resolve_workspace_subdomain called client.chat_getPermalink raw, bypassing the make_slack_api_rate_limited / make_slack_api_call_logged wrappers that every other Slack API call in this connector uses. On a 429 it would fall back to the configured workspace instead of retrying with Retry-After. Wrap it so a new workspace's first scrape resolves correctly under rate limiting. Index-time only (one call per channel); no behavior change to the read-time citation fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/danswer/connectors/slack/utils.py | 6 +++++- .../connectors/slack/test_normalize_slack_link.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/danswer/connectors/slack/utils.py b/backend/danswer/connectors/slack/utils.py index 27e1cca19ed..7f60dea6866 100644 --- a/backend/danswer/connectors/slack/utils.py +++ b/backend/danswer/connectors/slack/utils.py @@ -86,7 +86,11 @@ def resolve_workspace_subdomain( longer depends on a hand-typed `workspace` config being right. Falls back to `fallback` (the configured workspace) if the call fails.""" try: - resp = client.chat_getPermalink(channel=channel_id, message_ts=message_ts) + # 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"): diff --git a/backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py b/backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py index 29be426246a..d51ff844717 100644 --- a/backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py +++ b/backend/tests/unit/danswer/connectors/slack/test_normalize_slack_link.py @@ -5,15 +5,22 @@ 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) -> dict: + 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 {"ok": True, "permalink": self._permalink} + return _FakeResp({"ok": True, "permalink": self._permalink}) @pytest.mark.parametrize( From eaeeb557f30b268ab6700b3dcaab7a187a63995b Mon Sep 17 00:00:00 2001 From: rajiv chodisetti Date: Fri, 17 Jul 2026 16:03:37 +0530 Subject: [PATCH 4/4] k8s(prod): bump backend=vha-218 (getPermalink rate-limit) Co-Authored-By: Claude Opus 4.8 (1M context) --- k8s/overlays/prod/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml index 7d6a5140fae..21fe949643c 100644 --- a/k8s/overlays/prod/kustomization.yaml +++ b/k8s/overlays/prod/kustomization.yaml @@ -30,7 +30,7 @@ namespace: darwin images: - name: danswer-backend newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-backend - newTag: vha-217 + newTag: vha-218 - name: danswer-web-server newName: sfbrdevhelmweacr.azurecr.io/danswer/danswer-web-server newTag: vha-116