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.
- takopi_slack_plugin/__init__.py +1 -0
- takopi_slack_plugin/backend.py +193 -0
- takopi_slack_plugin/bridge.py +1380 -0
- takopi_slack_plugin/client.py +254 -0
- takopi_slack_plugin/commands/__init__.py +3 -0
- takopi_slack_plugin/commands/dispatch.py +114 -0
- takopi_slack_plugin/commands/executor.py +192 -0
- takopi_slack_plugin/config.py +60 -0
- takopi_slack_plugin/engine.py +142 -0
- takopi_slack_plugin/onboarding.py +58 -0
- takopi_slack_plugin/outbox.py +165 -0
- takopi_slack_plugin/overrides.py +20 -0
- takopi_slack_plugin/thread_sessions.py +289 -0
- takopi_slack_plugin-0.0.15.dist-info/METADATA +151 -0
- takopi_slack_plugin-0.0.15.dist-info/RECORD +17 -0
- takopi_slack_plugin-0.0.15.dist-info/WHEEL +4 -0
- takopi_slack_plugin-0.0.15.dist-info/entry_points.txt +3 -0
|
@@ -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,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()
|