takopi-slack-plugin 0.0.15__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,254 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import anyio
7
+ import httpx
8
+
9
+ from takopi.api import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class SlackApiError(RuntimeError):
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ error: str | None = None,
20
+ status_code: int | None = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.error = error
24
+ self.status_code = status_code
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class SlackAuth:
29
+ user_id: str
30
+ team_id: str | None = None
31
+ bot_id: str | None = None
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class SlackMessage:
36
+ ts: str
37
+ text: str | None
38
+ user: str | None
39
+ bot_id: str | None
40
+ subtype: str | None
41
+ thread_ts: str | None
42
+
43
+ @classmethod
44
+ def from_api(cls, payload: dict[str, Any]) -> "SlackMessage":
45
+ return cls(
46
+ ts=str(payload.get("ts") or ""),
47
+ text=payload.get("text"),
48
+ user=payload.get("user"),
49
+ bot_id=payload.get("bot_id"),
50
+ subtype=payload.get("subtype"),
51
+ thread_ts=payload.get("thread_ts"),
52
+ )
53
+
54
+
55
+ class SlackClient:
56
+ def __init__(
57
+ self,
58
+ token: str,
59
+ *,
60
+ base_url: str = "https://slack.com/api",
61
+ timeout_s: float = 30.0,
62
+ ) -> None:
63
+ self._token = token
64
+ self._client = httpx.AsyncClient(
65
+ base_url=base_url,
66
+ headers={"Authorization": f"Bearer {token}"},
67
+ timeout=timeout_s,
68
+ )
69
+
70
+ async def close(self) -> None:
71
+ await self._client.aclose()
72
+
73
+ async def _request(
74
+ self,
75
+ method: str,
76
+ endpoint: str,
77
+ *,
78
+ params: dict[str, Any] | None = None,
79
+ json: dict[str, Any] | None = None,
80
+ ) -> dict[str, Any]:
81
+ return await _request_with_client(
82
+ self._client,
83
+ method,
84
+ endpoint,
85
+ params=params,
86
+ json=json,
87
+ )
88
+
89
+ async def auth_test(self) -> SlackAuth:
90
+ payload = await self._request("POST", "/auth.test")
91
+ user_id = payload.get("user_id")
92
+ if not isinstance(user_id, str) or not user_id:
93
+ raise SlackApiError("Missing user_id in auth.test response")
94
+ return SlackAuth(
95
+ user_id=user_id,
96
+ team_id=payload.get("team_id"),
97
+ bot_id=payload.get("bot_id"),
98
+ )
99
+
100
+ async def post_message(
101
+ self,
102
+ *,
103
+ channel_id: str,
104
+ text: str,
105
+ blocks: list[dict[str, Any]] | None = None,
106
+ thread_ts: str | None = None,
107
+ reply_broadcast: bool | None = None,
108
+ ) -> SlackMessage:
109
+ data: dict[str, Any] = {
110
+ "channel": channel_id,
111
+ "text": text,
112
+ "mrkdwn": True,
113
+ }
114
+ if blocks is not None:
115
+ data["blocks"] = blocks
116
+ if thread_ts is not None:
117
+ data["thread_ts"] = thread_ts
118
+ if reply_broadcast is not None:
119
+ data["reply_broadcast"] = reply_broadcast
120
+ payload = await self._request("POST", "/chat.postMessage", json=data)
121
+ message = payload.get("message")
122
+ if not isinstance(message, dict):
123
+ raise SlackApiError("Slack postMessage missing message payload")
124
+ return SlackMessage.from_api(message)
125
+
126
+ async def update_message(
127
+ self,
128
+ *,
129
+ channel_id: str,
130
+ ts: str,
131
+ text: str,
132
+ blocks: list[dict[str, Any]] | None = None,
133
+ ) -> SlackMessage:
134
+ data: dict[str, Any] = {
135
+ "channel": channel_id,
136
+ "ts": ts,
137
+ "text": text,
138
+ "mrkdwn": True,
139
+ }
140
+ if blocks is not None:
141
+ data["blocks"] = blocks
142
+ payload = await self._request("POST", "/chat.update", json=data)
143
+ message = payload.get("message")
144
+ if not isinstance(message, dict):
145
+ raise SlackApiError("Slack update missing message payload")
146
+ return SlackMessage.from_api(message)
147
+
148
+ async def delete_message(self, *, channel_id: str, ts: str) -> bool:
149
+ data = {"channel": channel_id, "ts": ts}
150
+ await self._request("POST", "/chat.delete", json=data)
151
+ return True
152
+
153
+ async def post_response(
154
+ self,
155
+ *,
156
+ response_url: str,
157
+ text: str,
158
+ response_type: str = "ephemeral",
159
+ replace_original: bool | None = None,
160
+ delete_original: bool | None = None,
161
+ ) -> None:
162
+ payload: dict[str, Any] = {
163
+ "text": text,
164
+ "response_type": response_type,
165
+ }
166
+ if replace_original is not None:
167
+ payload["replace_original"] = replace_original
168
+ if delete_original is not None:
169
+ payload["delete_original"] = delete_original
170
+ async with httpx.AsyncClient(timeout=15.0) as client:
171
+ try:
172
+ response = await client.post(response_url, json=payload)
173
+ except httpx.HTTPError as exc:
174
+ logger.warning("slack.response_failed", error=str(exc))
175
+ return
176
+ if response.status_code >= 400:
177
+ logger.warning(
178
+ "slack.response_failed",
179
+ status_code=response.status_code,
180
+ body=response.text,
181
+ )
182
+
183
+ async def _request_with_client(
184
+ client: httpx.AsyncClient,
185
+ method: str,
186
+ endpoint: str,
187
+ *,
188
+ params: dict[str, Any] | None = None,
189
+ json: dict[str, Any] | None = None,
190
+ ) -> dict[str, Any]:
191
+ while True:
192
+ try:
193
+ response = await client.request(
194
+ method, endpoint, params=params, json=json
195
+ )
196
+ except httpx.HTTPError as exc:
197
+ logger.warning("slack.network_error", error=str(exc))
198
+ raise SlackApiError("Slack request failed") from exc
199
+
200
+ if response.status_code == 429:
201
+ retry_after = response.headers.get("Retry-After")
202
+ try:
203
+ delay = int(retry_after) if retry_after is not None else 1
204
+ except ValueError:
205
+ delay = 1
206
+ logger.info("slack.rate_limited", retry_after=delay)
207
+ await anyio.sleep(delay)
208
+ continue
209
+
210
+ if response.status_code >= 400:
211
+ raise SlackApiError(
212
+ f"Slack HTTP {response.status_code}",
213
+ status_code=response.status_code,
214
+ )
215
+
216
+ try:
217
+ payload = response.json()
218
+ except ValueError as exc:
219
+ raise SlackApiError("Slack response was not JSON") from exc
220
+
221
+ if payload.get("ok") is not True:
222
+ error = payload.get("error")
223
+ raise SlackApiError(
224
+ f"Slack API error: {error}",
225
+ error=error,
226
+ status_code=response.status_code,
227
+ )
228
+
229
+ return payload
230
+
231
+
232
+ async def open_socket_url(
233
+ app_token: str,
234
+ *,
235
+ base_url: str = "https://slack.com/api",
236
+ timeout_s: float = 30.0,
237
+ ) -> str:
238
+ token = app_token.strip()
239
+ if not token:
240
+ raise SlackApiError("Missing Slack app token")
241
+ async with httpx.AsyncClient(
242
+ base_url=base_url,
243
+ headers={"Authorization": f"Bearer {token}"},
244
+ timeout=timeout_s,
245
+ ) as client:
246
+ payload = await _request_with_client(
247
+ client,
248
+ "POST",
249
+ "/apps.connections.open",
250
+ )
251
+ url = payload.get("url")
252
+ if not isinstance(url, str) or not url.strip():
253
+ raise SlackApiError("Slack socket url missing")
254
+ return url.strip()
@@ -0,0 +1,3 @@
1
+ from .dispatch import dispatch_command, split_command_args
2
+
3
+ __all__ = ["dispatch_command", "split_command_args"]
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ import anyio
7
+ from takopi.commands import CommandContext, get_command
8
+ from takopi.config import ConfigError
9
+ from takopi.logging import get_logger
10
+ from takopi.model import EngineId, ResumeToken
11
+ from takopi.runner_bridge import RunningTasks
12
+ from takopi.runners.run_options import EngineRunOptions
13
+ from takopi.transport import MessageRef
14
+
15
+ from .executor import SlackCommandExecutor
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ def split_command_args(text: str) -> tuple[str, ...]:
21
+ if not text.strip():
22
+ return ()
23
+ try:
24
+ return tuple(shlex.split(text))
25
+ except ValueError:
26
+ return tuple(text.split())
27
+
28
+
29
+ async def dispatch_command(
30
+ cfg,
31
+ *,
32
+ command_id: str,
33
+ args_text: str,
34
+ full_text: str,
35
+ channel_id: str,
36
+ message_id: str,
37
+ thread_id: str | None,
38
+ reply_ref: MessageRef | None,
39
+ reply_text: str | None,
40
+ running_tasks: RunningTasks,
41
+ on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None,
42
+ default_engine_override: EngineId | None,
43
+ default_context,
44
+ engine_overrides_resolver: Callable[[EngineId], Awaitable[EngineRunOptions | None]]
45
+ | None,
46
+ ) -> bool:
47
+ allowlist = cfg.runtime.allowlist
48
+
49
+ executor = SlackCommandExecutor(
50
+ exec_cfg=cfg.exec_cfg,
51
+ runtime=cfg.runtime,
52
+ running_tasks=running_tasks,
53
+ on_thread_known=on_thread_known,
54
+ engine_overrides_resolver=engine_overrides_resolver,
55
+ channel_id=channel_id,
56
+ user_msg_id=message_id,
57
+ thread_id=thread_id,
58
+ show_resume_line=True,
59
+ default_engine_override=default_engine_override,
60
+ default_context=default_context,
61
+ )
62
+
63
+ message_ref = MessageRef(
64
+ channel_id=channel_id,
65
+ message_id=message_id,
66
+ thread_id=thread_id,
67
+ )
68
+
69
+ try:
70
+ backend = get_command(command_id, allowlist=allowlist, required=False)
71
+ except ConfigError as exc:
72
+ await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True)
73
+ return True
74
+
75
+ if backend is None:
76
+ return False
77
+
78
+ try:
79
+ plugin_config = cfg.runtime.plugin_config(command_id)
80
+ except ConfigError as exc:
81
+ await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True)
82
+ return True
83
+
84
+ ctx = CommandContext(
85
+ command=command_id,
86
+ text=full_text,
87
+ args_text=args_text,
88
+ args=split_command_args(args_text),
89
+ message=message_ref,
90
+ reply_to=reply_ref,
91
+ reply_text=reply_text,
92
+ config_path=cfg.runtime.config_path,
93
+ plugin_config=plugin_config,
94
+ runtime=cfg.runtime,
95
+ executor=executor,
96
+ )
97
+
98
+ try:
99
+ result = await backend.handle(ctx)
100
+ except Exception as exc:
101
+ logger.exception(
102
+ "command.failed",
103
+ command=command_id,
104
+ error=str(exc),
105
+ error_type=exc.__class__.__name__,
106
+ )
107
+ await executor.send(f"error:\n{exc}", reply_to=message_ref, notify=True)
108
+ return True
109
+
110
+ if result is not None:
111
+ reply_to = message_ref if result.reply_to is None else result.reply_to
112
+ await executor.send(result.text, reply_to=reply_to, notify=result.notify)
113
+
114
+ return True
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable, Sequence
4
+ from dataclasses import dataclass
5
+
6
+ import anyio
7
+
8
+ from takopi.commands import CommandExecutor, RunMode, RunRequest, RunResult
9
+ from takopi.context import RunContext
10
+ from takopi.model import EngineId, ResumeToken
11
+ from takopi.runner_bridge import ExecBridgeConfig, RunningTasks
12
+ from takopi.runners.run_options import EngineRunOptions
13
+ from takopi.transport import MessageRef, RenderedMessage, SendOptions
14
+ from takopi.transport_runtime import TransportRuntime
15
+
16
+ from ..engine import run_engine
17
+
18
+
19
+ class _CaptureTransport:
20
+ def __init__(self) -> None:
21
+ self._next_id = 1
22
+ self.last_message: RenderedMessage | None = None
23
+
24
+ async def send(
25
+ self,
26
+ *,
27
+ channel_id: int | str,
28
+ message: RenderedMessage,
29
+ options: SendOptions | None = None,
30
+ ) -> MessageRef:
31
+ thread_id = options.thread_id if options is not None else None
32
+ ref = MessageRef(channel_id=channel_id, message_id=self._next_id)
33
+ self._next_id += 1
34
+ self.last_message = message
35
+ return MessageRef(
36
+ channel_id=ref.channel_id,
37
+ message_id=ref.message_id,
38
+ thread_id=thread_id,
39
+ )
40
+
41
+ async def edit(
42
+ self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True
43
+ ) -> MessageRef:
44
+ _ = wait
45
+ self.last_message = message
46
+ return ref
47
+
48
+ async def delete(self, *, ref: MessageRef) -> bool:
49
+ _ = ref
50
+ return True
51
+
52
+ async def close(self) -> None:
53
+ return None
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class SlackCommandExecutor(CommandExecutor):
58
+ exec_cfg: ExecBridgeConfig
59
+ runtime: TransportRuntime
60
+ running_tasks: RunningTasks
61
+ on_thread_known: Callable[[ResumeToken, anyio.Event], Awaitable[None]] | None
62
+ engine_overrides_resolver: Callable[
63
+ [EngineId], Awaitable[EngineRunOptions | None]
64
+ ] | None
65
+ channel_id: str
66
+ user_msg_id: str
67
+ thread_id: str | None
68
+ show_resume_line: bool
69
+ default_engine_override: EngineId | None
70
+ default_context: RunContext | None
71
+
72
+ def _apply_default_context(self, request: RunRequest) -> RunRequest:
73
+ if request.context is not None or self.default_context is None:
74
+ return request
75
+ return RunRequest(
76
+ prompt=request.prompt,
77
+ engine=request.engine,
78
+ context=self.default_context,
79
+ )
80
+
81
+ def _apply_default_engine(self, request: RunRequest) -> RunRequest:
82
+ if request.engine is not None or self.default_engine_override is None:
83
+ return request
84
+ return RunRequest(
85
+ prompt=request.prompt,
86
+ engine=self.default_engine_override,
87
+ context=request.context,
88
+ )
89
+
90
+ async def send(
91
+ self,
92
+ message: RenderedMessage | str,
93
+ *,
94
+ reply_to: MessageRef | None = None,
95
+ notify: bool = True,
96
+ ) -> MessageRef | None:
97
+ rendered = (
98
+ message
99
+ if isinstance(message, RenderedMessage)
100
+ else RenderedMessage(text=message)
101
+ )
102
+ reply_ref = (
103
+ MessageRef(
104
+ channel_id=self.channel_id,
105
+ message_id=self.user_msg_id,
106
+ thread_id=self.thread_id,
107
+ )
108
+ if reply_to is None
109
+ else reply_to
110
+ )
111
+ return await self.exec_cfg.transport.send(
112
+ channel_id=self.channel_id,
113
+ message=rendered,
114
+ options=SendOptions(
115
+ reply_to=reply_ref,
116
+ notify=notify,
117
+ thread_id=self.thread_id,
118
+ ),
119
+ )
120
+
121
+ async def run_one(
122
+ self, request: RunRequest, *, mode: RunMode = "emit"
123
+ ) -> RunResult:
124
+ request = self._apply_default_context(request)
125
+ request = self._apply_default_engine(request)
126
+ engine = self.runtime.resolve_engine(
127
+ engine_override=request.engine,
128
+ context=request.context,
129
+ )
130
+ run_options = None
131
+ if self.engine_overrides_resolver is not None:
132
+ run_options = await self.engine_overrides_resolver(engine)
133
+
134
+ if mode == "capture":
135
+ capture = _CaptureTransport()
136
+ exec_cfg = ExecBridgeConfig(
137
+ transport=capture,
138
+ presenter=self.exec_cfg.presenter,
139
+ final_notify=False,
140
+ )
141
+ await run_engine(
142
+ exec_cfg=exec_cfg,
143
+ runtime=self.runtime,
144
+ running_tasks={},
145
+ channel_id=self.channel_id,
146
+ user_msg_id=self.user_msg_id,
147
+ text=request.prompt,
148
+ resume_token=None,
149
+ context=request.context,
150
+ engine_override=engine,
151
+ thread_id=self.thread_id,
152
+ on_thread_known=self.on_thread_known,
153
+ run_options=run_options,
154
+ )
155
+ return RunResult(engine=engine, message=capture.last_message)
156
+
157
+ await run_engine(
158
+ exec_cfg=self.exec_cfg,
159
+ runtime=self.runtime,
160
+ running_tasks=self.running_tasks,
161
+ channel_id=self.channel_id,
162
+ user_msg_id=self.user_msg_id,
163
+ text=request.prompt,
164
+ resume_token=None,
165
+ context=request.context,
166
+ engine_override=engine,
167
+ thread_id=self.thread_id,
168
+ on_thread_known=self.on_thread_known,
169
+ run_options=run_options,
170
+ )
171
+ return RunResult(engine=engine, message=None)
172
+
173
+ async def run_many(
174
+ self,
175
+ requests: Sequence[RunRequest],
176
+ *,
177
+ mode: RunMode = "emit",
178
+ parallel: bool = False,
179
+ ) -> list[RunResult]:
180
+ if not parallel:
181
+ return [await self.run_one(request, mode=mode) for request in requests]
182
+ results: list[RunResult | None] = [None] * len(requests)
183
+
184
+ async with anyio.create_task_group() as tg:
185
+
186
+ async def run_idx(idx: int, request: RunRequest) -> None:
187
+ results[idx] = await self.run_one(request, mode=mode)
188
+
189
+ for idx, request in enumerate(requests):
190
+ tg.start_soon(run_idx, idx, request)
191
+
192
+ return [result for result in results if result is not None]
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ from takopi.api import ConfigError
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class SlackTransportSettings:
12
+ bot_token: str
13
+ channel_id: str
14
+ app_token: str
15
+ message_overflow: Literal["trim", "split"] = "split"
16
+
17
+ @classmethod
18
+ def from_config(
19
+ cls, config: object, *, config_path: Path
20
+ ) -> "SlackTransportSettings":
21
+ if isinstance(config, SlackTransportSettings):
22
+ return config
23
+ if not isinstance(config, dict):
24
+ raise ConfigError(
25
+ f"Invalid `transports.slack` in {config_path}; expected a table."
26
+ )
27
+
28
+ bot_token = _require_str(config, "bot_token", config_path=config_path)
29
+ channel_id = _require_str(config, "channel_id", config_path=config_path)
30
+ app_token = _require_str(config, "app_token", config_path=config_path)
31
+
32
+ message_overflow = config.get("message_overflow", "split")
33
+ if not isinstance(message_overflow, str):
34
+ raise ConfigError(
35
+ f"Invalid `transports.slack.message_overflow` in {config_path}; "
36
+ "expected a string."
37
+ )
38
+ message_overflow = message_overflow.strip()
39
+ if message_overflow not in {"trim", "split"}:
40
+ raise ConfigError(
41
+ f"Invalid `transports.slack.message_overflow` in {config_path}; "
42
+ "expected 'trim' or 'split'."
43
+ )
44
+
45
+ return cls(
46
+ bot_token=bot_token,
47
+ channel_id=channel_id,
48
+ app_token=app_token,
49
+ message_overflow=message_overflow,
50
+ )
51
+
52
+
53
+ def _require_str(config: dict[str, Any], key: str, *, config_path: Path) -> str:
54
+ value = config.get(key)
55
+ if not isinstance(value, str) or not value.strip():
56
+ raise ConfigError(
57
+ f"Invalid `transports.slack.{key}` in {config_path}; "
58
+ "expected a non-empty string."
59
+ )
60
+ return value.strip()