imbi-slackbot 2.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ import re as _re
2
+ from importlib import metadata as _metadata
3
+
4
+ try:
5
+ version = _metadata.version('imbi-slackbot')
6
+ except _metadata.PackageNotFoundError:
7
+ version = '0.0.0'
8
+
9
+ version_info: list[int | str] = []
10
+ for _part in version.split('.'):
11
+ _match = _re.fullmatch(r'(\d+)(.*)', _part)
12
+ if _match is None:
13
+ version_info.append(_part)
14
+ else:
15
+ version_info.append(int(_match.group(1)))
16
+ if _match.group(2):
17
+ version_info.append(_match.group(2))
18
+ del _metadata, _re
imbi_slackbot/agent.py ADDED
@@ -0,0 +1,148 @@
1
+ """Non-streaming Claude tool loop for the Slack bot.
2
+
3
+ Given a reconstructed conversation, run Claude with the Imbi toolset,
4
+ executing any tool calls in a loop (up to ``max_rounds``) until Claude
5
+ stops requesting tools, and return the final assistant text.
6
+
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import typing
13
+
14
+ import anthropic
15
+
16
+ from imbi_slackbot import client, mcp
17
+
18
+ LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ def _collect(
22
+ content: list[typing.Any],
23
+ ) -> tuple[str, list[dict[str, typing.Any]], list[dict[str, typing.Any]]]:
24
+ """Split a response's content into text, assistant blocks, tool uses.
25
+
26
+ Returns:
27
+ ``(text, assistant_blocks, tool_uses)`` where ``assistant_blocks``
28
+ is the content to echo back as the assistant turn and ``tool_uses``
29
+ are the ``tool_use`` blocks to execute.
30
+
31
+ """
32
+ text_parts: list[str] = []
33
+ assistant_blocks: list[dict[str, typing.Any]] = []
34
+ tool_uses: list[dict[str, typing.Any]] = []
35
+ for block in content:
36
+ if block.type == 'text':
37
+ text_parts.append(block.text)
38
+ assistant_blocks.append({'type': 'text', 'text': block.text})
39
+ elif block.type == 'tool_use':
40
+ tool_block = {
41
+ 'type': 'tool_use',
42
+ 'id': block.id,
43
+ 'name': block.name,
44
+ 'input': block.input,
45
+ }
46
+ assistant_blocks.append(tool_block)
47
+ tool_uses.append(tool_block)
48
+ return '\n'.join(text_parts), assistant_blocks, tool_uses
49
+
50
+
51
+ def _tool_result_block(
52
+ tool_use_id: str,
53
+ content: str,
54
+ *,
55
+ is_error: bool,
56
+ ) -> dict[str, typing.Any]:
57
+ """Build an Anthropic ``tool_result`` block, flagged on failure."""
58
+ block: dict[str, typing.Any] = {
59
+ 'type': 'tool_result',
60
+ 'tool_use_id': tool_use_id,
61
+ 'content': content,
62
+ }
63
+ if is_error:
64
+ block['is_error'] = True
65
+ return block
66
+
67
+
68
+ async def run_turn(
69
+ *,
70
+ messages: list[dict[str, typing.Any]],
71
+ system: str,
72
+ tools: list[dict[str, typing.Any]] | None,
73
+ auth_token: str,
74
+ model: str,
75
+ max_tokens: int,
76
+ max_rounds: int,
77
+ ) -> str:
78
+ """Run one user turn through Claude and the tool loop.
79
+
80
+ Args:
81
+ messages: Conversation history in Anthropic message format. The
82
+ final message must be the user's latest input.
83
+ system: The system prompt.
84
+ tools: Anthropic tool definitions, or ``None`` for no tools.
85
+ auth_token: Per-user bearer token forwarded on every tool call.
86
+ model: Claude model id.
87
+ max_tokens: Max output tokens per response.
88
+ max_rounds: Max tool-use rounds before giving up.
89
+
90
+ Returns:
91
+ The assistant's final text (concatenated across rounds).
92
+
93
+ """
94
+ api_client = client.get_client()
95
+ manager = mcp.get_manager()
96
+ accumulated: list[str] = []
97
+ # Work on a copy so appending assistant/tool-result turns does not
98
+ # mutate the caller-supplied list.
99
+ messages = list(messages)
100
+
101
+ for _round in range(max_rounds):
102
+ kwargs: dict[str, typing.Any] = {
103
+ 'model': model,
104
+ 'max_tokens': max_tokens,
105
+ 'system': system,
106
+ 'messages': messages,
107
+ }
108
+ if tools:
109
+ kwargs['tools'] = tools
110
+
111
+ try:
112
+ response = typing.cast(
113
+ 'anthropic.types.Message',
114
+ await api_client.messages.create(**kwargs),
115
+ )
116
+ except anthropic.APIError:
117
+ LOGGER.exception('Anthropic API error')
118
+ return (
119
+ 'Sorry — I hit an error talking to the model. '
120
+ 'Please try again.'
121
+ )
122
+
123
+ text, assistant_blocks, tool_uses = _collect(response.content)
124
+ if text:
125
+ accumulated.append(text)
126
+
127
+ if response.stop_reason != 'tool_use' or not tool_uses:
128
+ break
129
+
130
+ messages.append({'role': 'assistant', 'content': assistant_blocks})
131
+
132
+ tool_results: list[dict[str, typing.Any]] = []
133
+ for tool_use in tool_uses:
134
+ result_text, is_error = await manager.execute_tool(
135
+ tool_use['name'],
136
+ tool_use['input'],
137
+ auth_token,
138
+ )
139
+ tool_results.append(
140
+ _tool_result_block(
141
+ tool_use['id'], result_text, is_error=is_error
142
+ )
143
+ )
144
+ messages.append({'role': 'user', 'content': tool_results})
145
+ else:
146
+ accumulated.append('_(Reached the tool-call limit before finishing.)_')
147
+
148
+ return '\n\n'.join(part for part in accumulated if part).strip()
imbi_slackbot/app.py ADDED
@@ -0,0 +1,91 @@
1
+ import contextlib
2
+ import datetime
3
+ from typing import TYPE_CHECKING
4
+
5
+ import fastapi
6
+ import typer
7
+ from imbi_common import graph, lifespan, sentry, server
8
+
9
+ import imbi_slackbot
10
+ from imbi_slackbot import (
11
+ app_status,
12
+ client,
13
+ identity,
14
+ links,
15
+ mcp,
16
+ slack_handler,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from collections import abc
21
+
22
+
23
+ @contextlib.asynccontextmanager
24
+ async def _anthropic_lifespan() -> abc.AsyncGenerator[None, None]:
25
+ await client.initialize()
26
+ try:
27
+ yield
28
+ finally:
29
+ await client.aclose()
30
+
31
+
32
+ @contextlib.asynccontextmanager
33
+ async def _mcp_lifespan() -> abc.AsyncGenerator[None, None]:
34
+ await mcp.initialize()
35
+ try:
36
+ yield
37
+ finally:
38
+ await mcp.aclose()
39
+
40
+
41
+ @contextlib.asynccontextmanager
42
+ async def _links_lifespan() -> abc.AsyncGenerator[None, None]:
43
+ await links.initialize()
44
+ yield
45
+
46
+
47
+ @contextlib.asynccontextmanager
48
+ async def _slack_lifespan() -> abc.AsyncGenerator[None, None]:
49
+ # Runs last so the graph, Anthropic client, and MCP tools are ready
50
+ # before the bot starts accepting Slack events.
51
+ await slack_handler.initialize()
52
+ try:
53
+ yield
54
+ finally:
55
+ await slack_handler.aclose()
56
+
57
+
58
+ def create_app() -> fastapi.FastAPI:
59
+ # Capture the shared graph connection for identity resolution once
60
+ # ``graph_lifespan`` opens it.
61
+ graph.set_on_startup(identity.on_graph_ready)
62
+
63
+ app = fastapi.FastAPI(
64
+ title='Imbi Slack Bot',
65
+ version=imbi_slackbot.version,
66
+ started_at=datetime.datetime.now(datetime.UTC),
67
+ lifespan=lifespan.Lifespan(
68
+ sentry.sentry_lifespan,
69
+ graph.graph_lifespan,
70
+ _anthropic_lifespan,
71
+ _mcp_lifespan,
72
+ _links_lifespan,
73
+ _slack_lifespan,
74
+ ),
75
+ )
76
+ app.include_router(app_status.router)
77
+ return app
78
+
79
+
80
+ cli = typer.Typer(no_args_is_help=True)
81
+ cli.command('serve')(
82
+ server.bind_entrypoint(
83
+ 'imbi_slackbot.app:create_app',
84
+ default_port=8004,
85
+ )
86
+ )
87
+
88
+
89
+ @cli.callback()
90
+ def _callback() -> None: # pyright: ignore[reportUnusedFunction]
91
+ """Imbi Slack Bot CLI"""
@@ -0,0 +1,41 @@
1
+ import datetime
2
+ import os
3
+ import typing as t
4
+
5
+ import fastapi
6
+ import pydantic
7
+
8
+ router = fastapi.APIRouter()
9
+
10
+
11
+ class Status(pydantic.BaseModel):
12
+ environment: t.Annotated[
13
+ str,
14
+ pydantic.Field(
15
+ description='Operating environment',
16
+ examples=['production'],
17
+ ),
18
+ ]
19
+ service: t.Annotated[
20
+ str, pydantic.Field(description='Service instance name')
21
+ ] = 'imbi-slackbot'
22
+ status: t.Literal['ok', 'failing']
23
+ version: t.Annotated[
24
+ str,
25
+ pydantic.Field(description='Application version', examples=['0.0.0']),
26
+ ]
27
+ started_at: datetime.datetime
28
+
29
+
30
+ @router.get(
31
+ '/status',
32
+ summary='Operational status',
33
+ operation_id='getStatus',
34
+ )
35
+ def status_endpoint(*, request: fastapi.Request) -> Status:
36
+ return Status(
37
+ environment=os.environ.get('ENVIRONMENT', 'development'),
38
+ status='ok',
39
+ version=request.app.version,
40
+ started_at=request.app.extra['started_at'],
41
+ )
@@ -0,0 +1,73 @@
1
+ """AsyncAnthropic client singleton for the Slack bot."""
2
+
3
+ import logging
4
+
5
+ import anthropic
6
+
7
+ from imbi_slackbot import settings
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+ _client: anthropic.AsyncAnthropic | None = None
12
+
13
+
14
+ async def initialize() -> None:
15
+ """Initialize the AsyncAnthropic client.
16
+
17
+ Non-fatal if the bot is disabled or no API key is configured.
18
+
19
+ """
20
+ global _client
21
+
22
+ await aclose()
23
+
24
+ slackbot_settings = settings.get_slackbot_settings()
25
+
26
+ if not slackbot_settings.enabled:
27
+ LOGGER.info('Slack bot is disabled')
28
+ return
29
+
30
+ if not slackbot_settings.api_key:
31
+ LOGGER.warning(
32
+ 'Slack bot enabled but no API key configured '
33
+ '(set ANTHROPIC_API_KEY)'
34
+ )
35
+ return
36
+
37
+ _client = anthropic.AsyncAnthropic(
38
+ api_key=slackbot_settings.api_key,
39
+ )
40
+ LOGGER.info('Slack bot Anthropic client initialized')
41
+
42
+
43
+ async def aclose() -> None:
44
+ """Close the AsyncAnthropic client."""
45
+ global _client
46
+ if _client is not None:
47
+ await _client.close()
48
+ _client = None
49
+ LOGGER.debug('Slack bot Anthropic client closed')
50
+
51
+
52
+ def get_client() -> anthropic.AsyncAnthropic:
53
+ """Get the AsyncAnthropic client singleton.
54
+
55
+ Returns:
56
+ The initialized AsyncAnthropic client.
57
+
58
+ Raises:
59
+ RuntimeError: If the client has not been initialized.
60
+
61
+ """
62
+ if _client is None:
63
+ raise RuntimeError(
64
+ 'Slack bot Anthropic client not initialized. '
65
+ 'Check that ANTHROPIC_API_KEY, SLACK_BOT_TOKEN, and '
66
+ 'SLACK_APP_TOKEN are set.'
67
+ )
68
+ return _client
69
+
70
+
71
+ def is_available() -> bool:
72
+ """Check if the Anthropic client is available."""
73
+ return _client is not None
@@ -0,0 +1,155 @@
1
+ """Map Slack users to Imbi users and mint per-user API tokens.
2
+
3
+ A Slack user is resolved to an Imbi user by email: the Slack profile
4
+ email (``users.info``) is matched against an active ``User`` node in the
5
+ graph. A short-lived per-user JWT access token is then minted with the
6
+ shared ``IMBI_AUTH_JWT_SECRET`` — identical to the token the Imbi API
7
+ issues at login — so every tool call the bot makes on the user's behalf
8
+ is authorized as, and limited to, that user.
9
+
10
+ Resolutions (including "no matching Imbi user") are cached with a TTL to
11
+ avoid hitting Slack and the graph on every message.
12
+
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import dataclasses
18
+ import datetime
19
+ import logging
20
+ import typing
21
+
22
+ from imbi_common import graph
23
+ from imbi_common.auth import core
24
+
25
+ from imbi_slackbot import settings
26
+
27
+ # slack_sdk's AsyncWebClient is only loosely typed (its responses are
28
+ # AsyncSlackResponse with Unknown generics), so treat it as Any rather
29
+ # than propagate Unknown through every call site.
30
+ SlackClient = typing.Any
31
+
32
+ LOGGER = logging.getLogger(__name__)
33
+
34
+ _USER_QUERY = """
35
+ MATCH (u:User {{email: {email}}})
36
+ RETURN u
37
+ """
38
+
39
+
40
+ @dataclasses.dataclass(frozen=True)
41
+ class ImbiUser:
42
+ """An Imbi user resolved from a Slack identity."""
43
+
44
+ email: str
45
+ display_name: str
46
+ is_admin: bool = False
47
+
48
+
49
+ @dataclasses.dataclass
50
+ class _CacheEntry:
51
+ """A cached resolution; ``user`` is ``None`` for a known non-user."""
52
+
53
+ user: ImbiUser | None
54
+ expires_at: datetime.datetime
55
+
56
+
57
+ # Shared graph instance captured at startup via ``graph.set_on_startup``.
58
+ _graph: graph.Graph | None = None
59
+ _cache: dict[str, _CacheEntry] = {}
60
+
61
+
62
+ def set_graph(db: graph.Graph) -> None:
63
+ """Capture the shared graph connection opened by ``graph_lifespan``."""
64
+ global _graph
65
+ _graph = db
66
+
67
+
68
+ async def on_graph_ready(db: graph.Graph) -> None:
69
+ """``graph.set_on_startup`` callback that captures the connection."""
70
+ set_graph(db)
71
+
72
+
73
+ def clear_cache() -> None:
74
+ """Drop all cached resolutions (used by tests and on shutdown)."""
75
+ _cache.clear()
76
+
77
+
78
+ async def _slack_email(
79
+ slack_client: SlackClient,
80
+ slack_user_id: str,
81
+ ) -> str | None:
82
+ """Return the Slack user's profile email, if any."""
83
+ response: typing.Any = await slack_client.users_info(user=slack_user_id)
84
+ user: typing.Any = response['user'] or {}
85
+ profile: typing.Any = user.get('profile') or {}
86
+ email: typing.Any = profile.get('email')
87
+ return str(email) if email else None
88
+
89
+
90
+ async def _load_imbi_user(email: str) -> ImbiUser | None:
91
+ """Look up an active Imbi ``User`` node by email."""
92
+ if _graph is None:
93
+ raise RuntimeError('Graph connection not initialized')
94
+ records = await _graph.execute(_USER_QUERY, {'email': email}, ['u'])
95
+ if not records:
96
+ return None
97
+ raw = graph.parse_agtype(records[0]['u'])
98
+ if not isinstance(raw, dict):
99
+ return None
100
+ data = typing.cast('dict[str, typing.Any]', raw)
101
+ if not data.get('is_active', True):
102
+ return None
103
+ return ImbiUser(
104
+ email=email,
105
+ display_name=str(data.get('display_name') or email),
106
+ is_admin=bool(data.get('is_admin', False)),
107
+ )
108
+
109
+
110
+ async def resolve(
111
+ slack_client: SlackClient,
112
+ slack_user_id: str,
113
+ *,
114
+ now: datetime.datetime | None = None,
115
+ ) -> ImbiUser | None:
116
+ """Resolve a Slack user id to an Imbi user, using the TTL cache.
117
+
118
+ Args:
119
+ slack_client: The Slack web client (for ``users.info``).
120
+ slack_user_id: The Slack user id (e.g. ``U012ABCDEF``).
121
+ now: Override the current time (for testing).
122
+
123
+ Returns:
124
+ The matching :class:`ImbiUser`, or ``None`` if the Slack user has
125
+ no email or no active Imbi account.
126
+
127
+ """
128
+ now = now or datetime.datetime.now(datetime.UTC)
129
+ entry = _cache.get(slack_user_id)
130
+ if entry is not None and entry.expires_at > now:
131
+ return entry.user
132
+
133
+ email = await _slack_email(slack_client, slack_user_id)
134
+ user = await _load_imbi_user(email) if email else None
135
+
136
+ ttl = settings.get_slackbot_settings().identity_cache_ttl
137
+ _cache[slack_user_id] = _CacheEntry(
138
+ user=user,
139
+ expires_at=now + datetime.timedelta(seconds=ttl),
140
+ )
141
+ if user is None:
142
+ LOGGER.info('No Imbi user for Slack user %s', slack_user_id)
143
+ return user
144
+
145
+
146
+ def mint_token(user: ImbiUser) -> str:
147
+ """Mint a per-user Imbi API access token.
148
+
149
+ The token is signed with the shared ``IMBI_AUTH_JWT_SECRET`` and
150
+ carries the user's email as its subject, exactly as the Imbi API
151
+ does at login. The API loads the user's permissions fresh from the
152
+ graph on each request keyed by this subject.
153
+
154
+ """
155
+ return core.create_access_token(user.email)
imbi_slackbot/links.py ADDED
@@ -0,0 +1,89 @@
1
+ """Fetch and cache the Imbi UI URL patterns (``llms.txt``).
2
+
3
+ The Imbi UI publishes an ``llms.txt`` at its root documenting the route
4
+ patterns for building deep links. This module fetches it once at startup
5
+ from the in-cluster ``IMBI_INTERNAL_UI_URL`` (falling back to the public
6
+ ``IMBI_UI_URL``) and caches it; if the UI is unreachable or neither is set
7
+ a built-in fallback is used so the system prompt always has URL guidance.
8
+
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ import httpx
16
+
17
+ from imbi_slackbot import settings
18
+
19
+ LOGGER = logging.getLogger(__name__)
20
+
21
+ # httpx.InvalidURL is a separate hierarchy from httpx.HTTPError, so a
22
+ # malformed IMBI_UI_URL must be caught explicitly to keep startup fail-soft.
23
+ _FETCH_ERRORS = (httpx.HTTPError, httpx.InvalidURL)
24
+
25
+ # Used when the UI's llms.txt cannot be fetched. Keep this roughly in sync
26
+ # with imbi-ui's public/llms.txt, which is the canonical source.
27
+ FALLBACK_URL_PATTERNS = """\
28
+ - `/dashboard`: the user's dashboard.
29
+ - `/projects`: the project inventory list.
30
+ - `/projects/{project_id}`: a project's detail page.
31
+ - `/projects/{project_id}/{tab}`: a project tab (overview, dependencies,
32
+ relationships, documents, configuration, logs, operations-log).
33
+ - `/operations-log`: the operations log.
34
+ - `/reports` and `/reports/{report_id}`: reports.
35
+ - `/settings` and `/settings/{tab}`: the current user's settings.
36
+ - `/users/{email}`: a user's profile page.
37
+ - `/admin/{section}`: an admin list view (sections: project-types,
38
+ environments, teams, organizations, blueprints, roles, users,
39
+ service-accounts).
40
+ - `/admin/{section}/new`: the create form.
41
+ - `/admin/{section}/{slug}`: an admin item's detail view.
42
+ - `/admin/{section}/{slug}/edit`: an admin item's edit form."""
43
+
44
+ _url_patterns: str | None = None
45
+
46
+
47
+ async def initialize() -> None:
48
+ """Fetch the UI's ``llms.txt`` and cache its contents."""
49
+ global _url_patterns
50
+ slackbot_settings = settings.get_slackbot_settings()
51
+ base_url = slackbot_settings.llms_base_url
52
+ if not base_url:
53
+ LOGGER.info(
54
+ 'No IMBI_INTERNAL_UI_URL or IMBI_UI_URL configured; '
55
+ 'using built-in URL patterns'
56
+ )
57
+ _url_patterns = FALLBACK_URL_PATTERNS
58
+ return
59
+
60
+ llms_url = f'{base_url.rstrip("/")}/llms.txt'
61
+ try:
62
+ async with httpx.AsyncClient(timeout=10) as client:
63
+ response = await client.get(llms_url)
64
+ response.raise_for_status()
65
+ text = response.text.strip()
66
+ except _FETCH_ERRORS:
67
+ LOGGER.warning(
68
+ 'Failed to load %s; using built-in URL patterns', llms_url
69
+ )
70
+ _url_patterns = FALLBACK_URL_PATTERNS
71
+ return
72
+
73
+ # An empty body or an HTML page (e.g. an SPA 404 fallback) is not
74
+ # usable URL guidance.
75
+ if not text or text.startswith('<'):
76
+ LOGGER.warning(
77
+ '%s did not return markdown; using built-in URL patterns',
78
+ llms_url,
79
+ )
80
+ _url_patterns = FALLBACK_URL_PATTERNS
81
+ return
82
+
83
+ _url_patterns = text
84
+ LOGGER.info('Loaded UI URL patterns from %s', llms_url)
85
+
86
+
87
+ def get_url_patterns() -> str:
88
+ """Return the cached UI URL patterns (or the built-in fallback)."""
89
+ return _url_patterns or FALLBACK_URL_PATTERNS