mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
feat: add Slack Block Kit reply options (#1407)
* feat: add Slack Block Kit reply options Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> * docs: document Slack interactivity setup Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
This commit is contained in:
parent
5e9dcdb21d
commit
c1e46b938e
7 changed files with 317 additions and 11 deletions
|
|
@ -286,7 +286,7 @@ Users can also override the team/project mapping per-comment by including `repo:
|
|||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From a manifest**
|
||||
2. Copy the manifest below, replacing the two placeholder URLs:
|
||||
- Replace `<your-provider-id>` with the OAuth provider ID from step 3a
|
||||
- Replace `<your-ngrok-url>` with the ngrok URL from step 2
|
||||
- Replace `<your-ngrok-url>` with the backend URL from step 2 (or your deployed LangGraph/FastAPI URL in production)
|
||||
|
||||
<details>
|
||||
<summary>Slack App Manifest</summary>
|
||||
|
|
@ -343,6 +343,10 @@ Users can also override the team/project mapping per-comment by including `repo:
|
|||
"message.mpim"
|
||||
]
|
||||
},
|
||||
"interactivity": {
|
||||
"is_enabled": true,
|
||||
"request_url": "https://<your-ngrok-url>/webhooks/slack/interactivity"
|
||||
},
|
||||
"org_deploy_enabled": false,
|
||||
"socket_mode_enabled": false,
|
||||
"token_rotation_enabled": false
|
||||
|
|
@ -354,6 +358,15 @@ Users can also override the team/project mapping per-comment by including `repo:
|
|||
|
||||
3. Install the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
|
||||
**Slack URL checklist:**
|
||||
|
||||
Both Slack URLs must point at the Open SWE backend that serves `agent.webapp:app` (locally, your ngrok URL forwarding to `langgraph dev`; in production, your LangGraph/FastAPI deployment URL), not the dashboard frontend URL.
|
||||
|
||||
- **Event Subscriptions → Request URL:** `https://<your-backend-url>/webhooks/slack`
|
||||
- **Interactivity & Shortcuts → Interactivity Request URL:** `https://<your-backend-url>/webhooks/slack/interactivity`
|
||||
|
||||
Slack Block Kit option buttons only work when Interactivity is enabled and pointed at `/webhooks/slack/interactivity`.
|
||||
|
||||
**Credentials you'll need:**
|
||||
|
||||
- `SLACK_BOT_TOKEN`: the Bot User OAuth Token (`xoxb-...`)
|
||||
|
|
@ -539,6 +552,7 @@ make dev # uv run langgraph dev
|
|||
| `POST /webhooks/linear` | Linear comment webhooks |
|
||||
| `GET /webhooks/linear` | Linear webhook verification |
|
||||
| `POST /webhooks/slack` | Slack event webhooks |
|
||||
| `POST /webhooks/slack/interactivity` | Slack Block Kit button interactions |
|
||||
| `GET /webhooks/slack` | Slack webhook verification |
|
||||
| `GET /dashboard/api/auth/login` | Dashboard GitHub OAuth login |
|
||||
| `GET /dashboard/api/auth/callback` | Dashboard GitHub OAuth callback (registered on the App in step 3b) |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -16,7 +17,11 @@ LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
|||
)
|
||||
|
||||
|
||||
def slack_thread_reply(message: str) -> dict[str, Any]:
|
||||
def slack_thread_reply(
|
||||
message: str,
|
||||
options: list[str] | None = None,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a message to the current Slack thread.
|
||||
|
||||
Use this for clarifying questions, mid-run progress updates, and the final
|
||||
|
|
@ -30,6 +35,10 @@ def slack_thread_reply(message: str) -> dict[str, Any]:
|
|||
bullet lists with "• ", ```code blocks```, > blockquotes.
|
||||
Do NOT use **bold**, [link](url), or other standard Markdown syntax.
|
||||
|
||||
To ask a user to choose from predefined options, pass `options`. Slack will
|
||||
render interactive buttons and the web UI will render the same choices.
|
||||
The user can still reply manually in the Slack thread.
|
||||
|
||||
To mention/tag a user, use Slack's mention format: <@USER_ID>.
|
||||
You can find user IDs in the conversation context (e.g. @Name(U06KD8BFY95)).
|
||||
Example: <@U06KD8BFY95> will tag that user in the message."""
|
||||
|
|
@ -49,7 +58,10 @@ def slack_thread_reply(message: str) -> dict[str, Any]:
|
|||
return {"success": False, "error": "Message cannot be empty"}
|
||||
|
||||
message = convert_mentions_to_slack_format(message)
|
||||
message_ts, slack_error = asyncio.run(_post_and_store_mapping(channel_id, thread_ts, message))
|
||||
slack_blocks = blocks or _build_option_blocks(message, options)
|
||||
message_ts, slack_error = asyncio.run(
|
||||
_post_and_store_mapping(channel_id, thread_ts, message, blocks=slack_blocks)
|
||||
)
|
||||
if message_ts is None:
|
||||
return {
|
||||
"success": False,
|
||||
|
|
@ -61,6 +73,29 @@ def slack_thread_reply(message: str) -> dict[str, Any]:
|
|||
return {"success": True}
|
||||
|
||||
|
||||
def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[str, Any]] | None:
|
||||
if not options:
|
||||
return None
|
||||
clean_options = [option.strip() for option in options if option.strip()]
|
||||
if not clean_options:
|
||||
return None
|
||||
return [
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": message}},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": option[:75], "emoji": True},
|
||||
"value": json.dumps({"type": "open_swe_option", "response": option}),
|
||||
"action_id": "open_swe_option_select",
|
||||
}
|
||||
for option in clean_options[:5]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _slack_reply_failure_hint(slack_error: str | None) -> str:
|
||||
if slack_error == "msg_too_long":
|
||||
return "Slack rejected the message as too long; retry with a shorter message."
|
||||
|
|
@ -79,9 +114,15 @@ def _slack_reply_failure_hint(slack_error: str | None) -> str:
|
|||
|
||||
|
||||
async def _post_and_store_mapping(
|
||||
channel_id: str, thread_ts: str, message: str
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
message_ts, slack_error = await post_slack_thread_reply_with_ts(channel_id, thread_ts, message)
|
||||
message_ts, slack_error = await post_slack_thread_reply_with_ts(
|
||||
channel_id, thread_ts, message, blocks=blocks
|
||||
)
|
||||
if message_ts:
|
||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
await store_slack_message_run_mapping(langgraph_client, channel_id, thread_ts, message_ts)
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@ async def post_slack_thread_reply_with_ts(
|
|||
*,
|
||||
unfurl_links: bool = True,
|
||||
unfurl_media: bool = True,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Post a reply in a Slack thread and return its Slack timestamp and error."""
|
||||
if not SLACK_BOT_TOKEN:
|
||||
|
|
@ -308,6 +309,8 @@ async def post_slack_thread_reply_with_ts(
|
|||
"unfurl_links": unfurl_links,
|
||||
"unfurl_media": unfurl_media,
|
||||
}
|
||||
if blocks:
|
||||
payload["blocks"] = blocks
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
|
|||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import parse_qs, quote
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
||||
|
|
@ -1443,6 +1443,86 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) ->
|
|||
return {"status": "accepted", "message": "Slack mention queued"}
|
||||
|
||||
|
||||
@app.post("/webhooks/slack/interactivity")
|
||||
async def slack_interactivity(
|
||||
request: Request, background_tasks: BackgroundTasks
|
||||
) -> dict[str, str]:
|
||||
"""Handle Slack Block Kit interactions."""
|
||||
body = await request.body()
|
||||
signature = request.headers.get("X-Slack-Signature", "")
|
||||
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
||||
if not verify_slack_signature(
|
||||
body=body,
|
||||
timestamp=timestamp,
|
||||
signature=signature,
|
||||
secret=SLACK_SIGNING_SECRET,
|
||||
):
|
||||
logger.warning("Invalid Slack interactivity signature")
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
form = parse_qs(body.decode("utf-8"))
|
||||
payload_raw = (form.get("payload") or [""])[0]
|
||||
try:
|
||||
payload = json.loads(payload_raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Failed to parse Slack interactivity payload")
|
||||
return {"status": "error", "message": "Invalid payload"}
|
||||
|
||||
action = _first_open_swe_option_action(payload.get("actions"))
|
||||
if action is None:
|
||||
return {"status": "ignored", "reason": "No Open SWE action"}
|
||||
|
||||
try:
|
||||
action_value = json.loads(str(action.get("value") or "{}"))
|
||||
except json.JSONDecodeError:
|
||||
return {"status": "ignored", "reason": "Invalid action value"}
|
||||
if action_value.get("type") != "open_swe_option":
|
||||
return {"status": "ignored", "reason": "Unknown action type"}
|
||||
|
||||
response = str(action_value.get("response") or "").strip()
|
||||
if not response:
|
||||
return {"status": "ignored", "reason": "Empty response"}
|
||||
|
||||
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
|
||||
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
|
||||
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
|
||||
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||
channel_id = str(channel.get("id") or container.get("channel_id") or "")
|
||||
event_ts = str(
|
||||
action.get("action_ts") or message.get("ts") or container.get("message_ts") or ""
|
||||
)
|
||||
thread_ts = str(
|
||||
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or event_ts
|
||||
)
|
||||
user_id = str(user.get("id") or "")
|
||||
if not channel_id or not thread_ts or not event_ts or not user_id:
|
||||
return {"status": "ignored", "reason": "Missing Slack action context"}
|
||||
|
||||
repo_config = await get_slack_repo_config(channel_id, thread_ts, slack_user_id=user_id)
|
||||
background_tasks.add_task(
|
||||
process_slack_mention,
|
||||
{
|
||||
"channel_id": channel_id,
|
||||
"thread_ts": thread_ts,
|
||||
"event_ts": event_ts,
|
||||
"user_id": user_id,
|
||||
"text": response,
|
||||
"bot_user_id": SLACK_BOT_USER_ID,
|
||||
},
|
||||
repo_config,
|
||||
)
|
||||
return {"status": "accepted", "message": "Slack option queued"}
|
||||
|
||||
|
||||
def _first_open_swe_option_action(actions: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(actions, list):
|
||||
return None
|
||||
for action in actions:
|
||||
if isinstance(action, dict) and action.get("action_id") == "open_swe_option_select":
|
||||
return action
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/webhooks/slack")
|
||||
async def slack_webhook_verify() -> dict[str, str]:
|
||||
"""Verify endpoint for Slack webhook setup."""
|
||||
|
|
|
|||
|
|
@ -230,6 +230,23 @@ async def test_post_slack_thread_reply_with_ts_returns_http_error(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_slack_thread_reply_with_ts_sends_blocks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
||||
|
||||
blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "Pick"}}]
|
||||
client_cm = _async_client_cm(_ok_response())
|
||||
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
||||
result = await slack_utils.post_slack_thread_reply_with_ts(
|
||||
"C1", "1.0", "Pick", blocks=blocks
|
||||
)
|
||||
|
||||
assert result == ("1.0", None)
|
||||
assert client_cm.post.call_args.kwargs["json"]["blocks"] == blocks
|
||||
|
||||
|
||||
async def test_post_slack_thread_reply_preserves_bool_return_on_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ def test_slack_thread_reply_returns_structured_error_for_msg_too_long(
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_post_and_store_mapping(
|
||||
channel_id: str, thread_ts: str, message: str
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
return None, "msg_too_long"
|
||||
|
||||
|
|
@ -47,7 +51,11 @@ def test_slack_thread_reply_hints_not_to_retry_channel_errors(
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_post_and_store_mapping(
|
||||
channel_id: str, thread_ts: str, message: str
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
return None, slack_error
|
||||
|
||||
|
|
@ -68,7 +76,11 @@ def test_slack_thread_reply_rate_limited_hint_includes_retry_after(
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_post_and_store_mapping(
|
||||
channel_id: str, thread_ts: str, message: str
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
return None, "rate_limited: 30"
|
||||
|
||||
|
|
@ -88,7 +100,11 @@ def test_slack_thread_reply_rate_limited_hint_without_retry_after(
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_post_and_store_mapping(
|
||||
channel_id: str, thread_ts: str, message: str
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
return None, "rate_limited"
|
||||
|
||||
|
|
@ -106,7 +122,11 @@ def test_slack_thread_reply_uses_post_failed_without_slack_error(
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_post_and_store_mapping(
|
||||
channel_id: str, thread_ts: str, message: str
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
return None, None
|
||||
|
||||
|
|
@ -119,3 +139,33 @@ def test_slack_thread_reply_uses_post_failed_without_slack_error(
|
|||
assert result["error"] == "post failed"
|
||||
assert result["slack_error"] is None
|
||||
assert result["message_chars"] == 5
|
||||
|
||||
|
||||
def test_slack_thread_reply_builds_option_blocks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_post_and_store_mapping(
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
captured.update(
|
||||
{"channel_id": channel_id, "thread_ts": thread_ts, "message": message, "blocks": blocks}
|
||||
)
|
||||
return "2.0", None
|
||||
|
||||
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
|
||||
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)
|
||||
|
||||
result = slack_reply_tool.slack_thread_reply("Pick one", options=["A", "B"])
|
||||
|
||||
assert result == {"success": True}
|
||||
assert captured["channel_id"] == "C1"
|
||||
assert captured["thread_ts"] == "1.0"
|
||||
assert captured["message"] == "Pick one"
|
||||
actions = captured["blocks"][1]
|
||||
assert actions["type"] == "actions"
|
||||
assert [button["text"]["text"] for button in actions["elements"]] == ["A", "B"]
|
||||
assert actions["elements"][0]["action_id"] == "open_swe_option_select"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,28 @@ const SLACK_TOKEN = /<([^>]+)>/g
|
|||
const LINK_CLASS =
|
||||
"text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]"
|
||||
|
||||
type SlackTextObject = { type?: string; text?: string }
|
||||
type SlackBlock = {
|
||||
type?: string
|
||||
text?: SlackTextObject
|
||||
elements?: Array<{ type?: string; text?: SlackTextObject }>
|
||||
}
|
||||
|
||||
function isSlackTextObject(value: unknown): value is SlackTextObject {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as SlackTextObject).text === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function isSlackBlockArray(value: unknown): value is SlackBlock[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((block) => !!block && typeof block === "object")
|
||||
)
|
||||
}
|
||||
|
||||
// Slack mrkdwn isn't standard Markdown — rewrite its link/mention syntax for display
|
||||
// rather than feeding it to the Markdown renderer (which mis-renders *bold* etc.).
|
||||
function renderSlackBody(text: string): Array<ReactNode> {
|
||||
|
|
@ -71,11 +93,88 @@ function renderSlackBody(text: string): Array<ReactNode> {
|
|||
return nodes
|
||||
}
|
||||
|
||||
function blocksFromOptions(
|
||||
message: string,
|
||||
options: unknown
|
||||
): SlackBlock[] | null {
|
||||
if (!Array.isArray(options)) return null
|
||||
const cleanOptions = options.filter(
|
||||
(option): option is string =>
|
||||
typeof option === "string" && option.trim().length > 0
|
||||
)
|
||||
if (cleanOptions.length === 0) return null
|
||||
return [
|
||||
{ type: "section", text: { type: "mrkdwn", text: message } },
|
||||
{
|
||||
type: "actions",
|
||||
elements: cleanOptions.slice(0, 5).map((option) => ({
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: option },
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function renderSlackBlocks(blocks: SlackBlock[]): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{blocks.map((block, index) => {
|
||||
if (
|
||||
(block.type === "section" || block.type === "context") &&
|
||||
isSlackTextObject(block.text)
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="[overflow-wrap:anywhere] break-words whitespace-pre-wrap"
|
||||
>
|
||||
{renderSlackBody(block.text.text ?? "")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (block.type === "actions" && Array.isArray(block.elements)) {
|
||||
return (
|
||||
<div key={index} className="flex flex-wrap gap-2">
|
||||
{block.elements.map((element, elementIndex) => {
|
||||
const label = isSlackTextObject(element.text)
|
||||
? element.text.text
|
||||
: element.type || "Action"
|
||||
return (
|
||||
<span
|
||||
key={elementIndex}
|
||||
className="rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel)] px-2 py-1 text-[12px] text-[color:var(--ui-text)]"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (block.type === "divider") {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border-t border-[var(--ui-border-subtle)]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) {
|
||||
const isLinear = chunk.toolKind === "linear"
|
||||
const body =
|
||||
((isLinear ? chunk.input?.comment_body : chunk.input?.message) as string) ||
|
||||
""
|
||||
const blocks = !isLinear
|
||||
? isSlackBlockArray(chunk.input?.blocks)
|
||||
? chunk.input.blocks
|
||||
: blocksFromOptions(body, chunk.input?.options)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
|
|
@ -88,6 +187,8 @@ export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) {
|
|||
<div className="max-h-[250px] overflow-auto px-3 py-2 text-[13px] text-[color:var(--ui-text)]">
|
||||
{isLinear ? (
|
||||
<Markdown content={body} />
|
||||
) : blocks ? (
|
||||
renderSlackBlocks(blocks)
|
||||
) : (
|
||||
<div className="[overflow-wrap:anywhere] break-words whitespace-pre-wrap">
|
||||
{renderSlackBody(body)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue