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.
- imbi_slackbot/__init__.py +18 -0
- imbi_slackbot/agent.py +148 -0
- imbi_slackbot/app.py +91 -0
- imbi_slackbot/app_status.py +41 -0
- imbi_slackbot/client.py +73 -0
- imbi_slackbot/identity.py +155 -0
- imbi_slackbot/links.py +89 -0
- imbi_slackbot/mcp.py +248 -0
- imbi_slackbot/py.typed +0 -0
- imbi_slackbot/settings.py +111 -0
- imbi_slackbot/slack_handler.py +223 -0
- imbi_slackbot/system_prompt.md +76 -0
- imbi_slackbot/system_prompt.py +92 -0
- imbi_slackbot-2.8.0.dist-info/METADATA +77 -0
- imbi_slackbot-2.8.0.dist-info/RECORD +18 -0
- imbi_slackbot-2.8.0.dist-info/WHEEL +4 -0
- imbi_slackbot-2.8.0.dist-info/entry_points.txt +2 -0
- imbi_slackbot-2.8.0.dist-info/licenses/LICENSE +28 -0
|
@@ -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
|
+
)
|
imbi_slackbot/client.py
ADDED
|
@@ -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
|