Skip to content
Merged
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
11 changes: 10 additions & 1 deletion src/chat_sdk/adapters/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ def __init__(self, config: DiscordAdapterConfig | None = None) -> None:
"application_id is required. Set DISCORD_APPLICATION_ID or provide it in config.",
)

# Custom Discord API base URL (proxy / mock / self-host). Port of
# upstream's coalescing chain ``config.apiUrl ?? process.env.DISCORD_API_URL
# ?? DISCORD_API_BASE`` (index.ts:142). Unlike Slack/GitHub/Linear --
# which feed clients via a truthy spread -- ``_discord_fetch`` joins
# ``f"{base}{path}"`` directly, so an empty ``apiUrl`` would yield a
# broken relative URL. We use a truthy fallback so an empty string (or
# env) resolves to the ``DISCORD_API_BASE`` default, matching the
# empty-string-is-default contract shared by the other adapters.
self._api_base_url: str = config.api_url or os.environ.get("DISCORD_API_URL") or DISCORD_API_BASE
self._name = "discord"
self._bot_token = bot_token
self._public_key = public_key.strip().lower()
Expand Down Expand Up @@ -1494,7 +1503,7 @@ async def _discord_fetch(
"""
import aiohttp # lazy import (needed for FormData)

url = f"{DISCORD_API_BASE}{path}"
url = f"{self._api_base_url}{path}"
headers: dict[str, str] = {
"Authorization": f"Bot {self._bot_token}",
}
Expand Down
5 changes: 5 additions & 0 deletions src/chat_sdk/adapters/discord/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class DiscordAdapterConfig:
See: https://discord.com/developers/docs/getting-started
"""

# Override the Discord API base URL. Defaults to the DISCORD_API_URL env
# var, then to "https://discord.com/api/v10". Mirrors upstream
# ``config.apiUrl ?? process.env.DISCORD_API_URL ?? DISCORD_API_BASE``
# (vercel/chat adapter-discord index.ts:142).
api_url: str | None = None
# Discord application ID. Defaults to DISCORD_APPLICATION_ID env var.
application_id: str | None = None
# Discord bot token. Defaults to DISCORD_BOT_TOKEN env var.
Expand Down
58 changes: 56 additions & 2 deletions src/chat_sdk/adapters/github/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,20 @@
RawMessage,
StreamChunk,
StreamOptions,
Thread,
ThreadInfo,
ThreadSummary,
UserInfo,
WebhookOptions,
_parse_iso,
)

# Default GitHub REST API base URL. Overridable per-adapter via
# ``config["api_url"]`` / ``GITHUB_API_URL`` for GitHub Enterprise Server
# (mirrors upstream Octokit ``baseUrl``). Stored without a trailing slash so
# ``f"{base}{path}"`` joins cleanly with leading-slash paths.
GITHUB_API_BASE_URL = "https://api.github.com"

REVIEW_COMMENT_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):(\d+):rc:(\d+)$")
ISSUE_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):issue:(\d+)$")
PR_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):(\d+)$")
Expand Down Expand Up @@ -114,6 +121,19 @@ def __init__(self, config: GitHubAdapterConfig | None = None) -> None:
self._chat: ChatInstance | None = None
self._format_converter = GitHubFormatConverter()

# Custom GitHub API base URL (e.g. GitHub Enterprise Server). Upstream
# threads ``config.apiUrl ?? process.env.GITHUB_API_URL`` into every
# Octokit ``baseUrl`` via the truthy spread ``...(this.apiUrl ? { baseUrl }
# : {})`` (index.ts:201/217/...), so an empty string falls back to the
# default. We have no Octokit -- our REST calls go through
# ``_github_api_request`` and the installation-token exchange -- so we
# normalize the override (strip a trailing slash so ``f"{base}{path}"``
# joins cleanly) and substitute it for the hardcoded
# ``https://api.github.com`` at both sites. The truthy fallback means an
# empty ``apiUrl`` (or env) uses the default endpoint.
api_url_raw = config.get("api_url") or os.environ.get("GITHUB_API_URL")
self._api_url = api_url_raw.rstrip("/") if api_url_raw else GITHUB_API_BASE_URL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The truthiness check if api_url_raw will evaluate to False if api_url_raw is an explicit empty string (""), causing it to incorrectly fall back to GITHUB_API_BASE_URL. This violates the general rule of using is not None for optional values that can be falsy but valid, and contradicts the comment's stated intent to honor explicit empty strings.

Please use is not None instead of a truthiness check.

Suggested change
self._api_url = api_url_raw.rstrip("/") if api_url_raw else GITHUB_API_BASE_URL
self._api_url = api_url_raw.rstrip("/") if api_url_raw is not None else GITHUB_API_BASE_URL
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.


# Auth configuration
self._auth_token: str | None = None
self._app_credentials: dict[str, str] | None = None
Expand Down Expand Up @@ -1098,7 +1118,7 @@ async def _get_installation_token(self, installation_id: int) -> str:
return token

app_jwt = self._generate_app_jwt()
url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
url = f"{self._api_url}/app/installations/{installation_id}/access_tokens"
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {app_jwt}",
Expand Down Expand Up @@ -1169,7 +1189,7 @@ async def _github_api_request(
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"

url = f"https://api.github.com{path}" if path.startswith("/") else path
url = f"{self._api_url}{path}" if path.startswith("/") else path

session = await self._get_http_session()
kwargs: dict[str, Any] = {"headers": headers}
Expand Down Expand Up @@ -1208,6 +1228,40 @@ async def _get_installation_id(self, owner: str, repo: str) -> int | None:
key = f"github:install:{owner}/{repo}"
return await self._chat.get_state().get(key)

async def get_installation_id(self, thread: Thread | str) -> int | None:
"""Get the GitHub App installation ID associated with a thread.

Returns the fixed installation ID in single-tenant app mode, the cached
repository installation in multi-tenant mode, or ``None`` in PAT mode.

Faithful port of upstream ``getInstallationId`` (index.ts:458-480).
``thread`` may be a :class:`Thread` or a raw thread-ID string. The
private :meth:`_get_installation_id` (owner/repo) is retained as the
storage-keyed helper this method delegates to in multi-tenant mode.

Raises:
ValidationError: In multi-tenant mode when the adapter has not been
initialized via ``chat.initialize()`` (no state store to read).
"""
# Single-tenant GitHub App mode: a fixed installation was configured.
if self._installation_id is not None:
return self._installation_id

# PAT mode (or any non-multi-tenant config): no installation concept.
if not self.is_multi_tenant:
return None

thread_id = thread if isinstance(thread, str) else thread.id
decoded = self.decode_thread_id(thread_id)

if not self._chat:
raise ValidationError(
"github",
"Adapter not initialized. Ensure chat.initialize() has been called first.",
)

return await self._get_installation_id(decoded.owner, decoded.repo)

@staticmethod
async def _get_request_body(request: Any) -> str:
"""Extract body text from a request object."""
Expand Down
6 changes: 6 additions & 0 deletions src/chat_sdk/adapters/github/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ class GitHubAdapterBaseConfig(TypedDict, total=False):
"""Base configuration options shared by all auth methods.

Attributes:
api_url: Override the GitHub API base URL (e.g. a GitHub Enterprise
Server endpoint like "https://github.example.com/api/v3"). Mirrors
upstream ``config.apiUrl`` → Octokit ``baseUrl`` (index.ts:201,217).
Defaults to the GITHUB_API_URL env var, then to
"https://api.github.com".
bot_user_id: Bot's GitHub user ID (numeric). Used for self-message
detection. If not provided, will be fetched on first API call.
logger: Logger instance for error reporting. Defaults to ConsoleLogger.
Expand All @@ -27,6 +32,7 @@ class GitHubAdapterBaseConfig(TypedDict, total=False):
Defaults to GITHUB_WEBHOOK_SECRET env var.
"""

api_url: str
bot_user_id: int
logger: Logger
user_name: str
Expand Down
13 changes: 12 additions & 1 deletion src/chat_sdk/adapters/linear/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ def __init__(self, config: LinearAdapterConfig | None = None) -> None:
)

self._name = "linear"
# Custom Linear GraphQL endpoint (proxy / mock / self-host). Faithful
# port of upstream ``config.apiUrl ?? process.env.LINEAR_API_URL``
# (index.ts:239), consumed via the truthy spread
# ``...(this.apiUrl ? { apiUrl } : {})`` at every ``LinearClient``
# construction — so an empty string falls back to the default endpoint.
# We have no LinearClient -- ``_graphql_query`` POSTs raw GraphQL -- so
# the override substitutes for the module-level ``LINEAR_API_URL``
# default. The truthy check means an empty ``apiUrl`` (or env) uses the
# default rather than POSTing to an empty/relative URL.
config_api_url = getattr(config, "api_url", None)
self._api_url: str = config_api_url or os.environ.get("LINEAR_API_URL") or LINEAR_API_URL
self._webhook_secret = webhook_secret
self._logger: Logger = getattr(config, "logger", None) or ConsoleLogger("info", prefix="linear")
self._user_name = getattr(config, "user_name", None) or os.environ.get("LINEAR_BOT_USERNAME", "linear-bot")
Expand Down Expand Up @@ -1168,7 +1179,7 @@ async def _graphql_query(

session = await self._get_http_session()
async with session.post(
LINEAR_API_URL,
self._api_url,
headers=headers,
json=payload,
) as response:
Expand Down
6 changes: 6 additions & 0 deletions src/chat_sdk/adapters/linear/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
class LinearAdapterBaseConfig:
"""Base configuration options shared by all auth methods."""

# Override the Linear GraphQL API base URL. Defaults to the LINEAR_API_URL
# env var, then to "https://api.linear.app/graphql". Mirrors upstream
# ``config.apiUrl ?? process.env.LINEAR_API_URL`` → ``LinearClient.apiUrl``
# (vercel/chat adapter-linear index.ts:239, types.ts:51). Useful for
# proxies, mocks, or self-hosted GraphQL gateways.
api_url: str | None = None
# Logger instance for error reporting. Defaults to ConsoleLogger.
logger: Logger | None = None
# Bot display name for @-mention detection.
Expand Down
28 changes: 26 additions & 2 deletions src/chat_sdk/adapters/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,18 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
if encryption_key_raw:
self._encryption_key = decode_key(encryption_key_raw)

# Custom Slack Web API base URL (e.g. proxy, mock, Enterprise routing).
# ``config.apiUrl ?? process.env.SLACK_API_URL`` upstream (index.ts:617),
# consumed via the truthy spread ``...(this.slackApiUrl ? {...} : {})``
# at every client construction — so an empty string falls back to the
# built-in default. We mirror that here: an empty ``apiUrl`` (or env)
# resolves to ``None``. When ``None`` we omit ``base_url`` from the
# client constructors entirely, so slack_sdk keeps its built-in
# ``https://slack.com/api/`` default (it rejects ``base_url=None``).
# Threaded into BOTH the async ``AsyncWebClient`` cache and the
# synchronous ``web_client`` escape hatch.
self._slack_api_url: str | None = (config.api_url or os.environ.get("SLACK_API_URL")) or None

# ------------------------------------------------------------------
# Properties (Adapter protocol)
# ------------------------------------------------------------------
Expand Down Expand Up @@ -852,7 +864,14 @@ def _get_client(self, token: str | None = None) -> Any:

from slack_sdk.web.async_client import AsyncWebClient

client = AsyncWebClient(token=resolved_token)
# Only pass ``base_url`` when a truthy override is configured — slack_sdk
# rejects ``base_url=None`` (it requires a string), and an empty string
# must fall back to the built-in default, so mirroring upstream's truthy
# spread keeps the default otherwise.
client_kwargs: dict[str, Any] = {"token": resolved_token}
if self._slack_api_url:
client_kwargs["base_url"] = self._slack_api_url
client = AsyncWebClient(**client_kwargs)
self._client_cache[resolved_token] = client
if len(self._client_cache) > self._client_cache_max:
# Evict oldest (LRU). We intentionally do NOT close the evicted
Expand Down Expand Up @@ -895,7 +914,12 @@ def _get_web_client_for_token(self, token: str) -> Any:
if client is None:
from slack_sdk import WebClient

client = WebClient(token=token)
# Same truthy ``base_url`` rule as ``_get_client`` — pass the
# override only when truthy so slack_sdk keeps its default.
web_client_kwargs: dict[str, Any] = {"token": token}
if self._slack_api_url:
web_client_kwargs["base_url"] = self._slack_api_url
client = WebClient(**web_client_kwargs)
self._web_client_cache[token] = client
return client

Expand Down
8 changes: 8 additions & 0 deletions src/chat_sdk/adapters/slack/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@
class SlackAdapterConfig:
"""Configuration for the Slack adapter."""

# Override the Slack Web API base URL (passed as ``base_url`` to every
# ``slack_sdk`` client the adapter builds — the default client, the
# per-token async cache, and the synchronous ``web_client`` escape hatch).
# Defaults to the ``SLACK_API_URL`` env var, then to slack_sdk's built-in
# ``https://slack.com/api/``. Mirrors upstream ``config.apiUrl`` →
# ``slackApiUrl`` (vercel/chat 6b17c60). Useful for proxies, Slack-API
# mocks in tests, or Enterprise-routed deployments.
api_url: str | None = None
# App-level token (xapp-...). Required when ``mode == "socket"``.
app_token: str | None = None
# Bot token (xoxb-...). Required for single-workspace mode. Omit for multi-workspace.
Expand Down
Loading
Loading