Skip to content

Commit 463be1a

Browse files
committed
feat(slack): implement App Home dashboard with persistent generation button
1 parent 169312f commit 463be1a

File tree

3 files changed

+257
-50
lines changed

3 files changed

+257
-50
lines changed

backend/api/routes/feedback.py

Lines changed: 132 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
from typing import cast
32

43
import httpx
54
import structlog
@@ -9,7 +8,11 @@
98
from backend.config.settings import settings
109
from backend.workers.tasks.generate_draft import generate_draft_task
1110
from backend.workers.tasks.publish_post import publish_post_task
12-
from slack_app.utils.block_builder import build_approval_modal
11+
from slack_app.utils.block_builder import (
12+
build_app_home,
13+
build_approval_modal,
14+
build_generation_modal,
15+
)
1316

1417
logger = structlog.get_logger()
1518
router = APIRouter(prefix="/slack", tags=["Slack Integration"])
@@ -19,41 +22,30 @@
1922
async def slack_slash_command(request: Request):
2023
form_data = await request.form()
2124
command = form_data.get("command")
22-
text = str(form_data.get("text", "")).strip()
23-
user_id = cast(str, form_data.get("user_id", ""))
24-
channel_id = cast(str, form_data.get("channel_id", ""))
25+
trigger_id = form_data.get("trigger_id")
26+
channel_id = str(form_data.get("channel_id"))
2527

2628
if command != "/draft":
2729
return {"response_type": "ephemeral", "text": SLACK_UI["cmd_unknown"]}
2830

29-
if not text:
30-
return {"response_type": "ephemeral", "text": SLACK_UI["cmd_missing_args"]}
31-
32-
parts = [p.strip() for p in text.split("|")]
33-
topic = parts[0]
34-
platform = parts[1].lower() if len(parts) > 1 else "telegram"
35-
36-
valid_platforms = ["telegram", "twitter", "threads"]
37-
if platform not in valid_platforms:
38-
return {
39-
"response_type": "ephemeral",
40-
"text": SLACK_UI["cmd_invalid_platform"].format(
41-
platform=platform, valid_platforms=", ".join(valid_platforms)
42-
),
43-
}
44-
45-
logger.info(
46-
"slack_command_received", topic=topic, platform=platform, user_id=user_id
31+
# Відкриваємо модалку замість парсингу тексту
32+
modal_view = build_generation_modal(channel_id=channel_id)
33+
slack_token = (
34+
settings.SLACK_BOT_TOKEN.get_secret_value() if settings.SLACK_BOT_TOKEN else ""
4735
)
36+
headers = {"Authorization": f"Bearer {slack_token}"}
4837

49-
await generate_draft_task.kiq( # type: ignore[call-overload]
50-
topic=topic, platform=platform, user_id=user_id, channel_id=channel_id
51-
)
38+
async with httpx.AsyncClient() as client:
39+
res = await client.post(
40+
"https://slack.com/api/views.open",
41+
headers=headers,
42+
json={"trigger_id": trigger_id, "view": modal_view},
43+
)
44+
if not res.json().get("ok"):
45+
logger.error("slack_modal_open_error", error=res.json())
5246

53-
return {
54-
"response_type": "in_channel",
55-
"text": SLACK_UI["cmd_accepted"].format(topic=topic, platform=platform),
56-
}
47+
# Повертаємо 200 OK без тіла, щоб Slack не дублював повідомлення
48+
return Response(status_code=200)
5749

5850

5951
@router.post("/interactions")
@@ -169,34 +161,125 @@ async def slack_interactions(request: Request):
169161
},
170162
)
171163

164+
elif action_id == "action_open_generation_modal":
165+
logger.info("slack_home_generation_btn_clicked", user_id=user_id)
166+
modal_view = build_generation_modal(channel_id=user_id)
167+
async with httpx.AsyncClient() as client:
168+
res = await client.post(
169+
"https://slack.com/api/views.open",
170+
headers=headers,
171+
json={"trigger_id": trigger_id, "view": modal_view},
172+
)
173+
if not res.json().get("ok"):
174+
logger.error("slack_modal_error", error=res.json())
172175
return Response(status_code=200)
173176

174177
elif interaction_type == "view_submission":
175178
view = payload.get("view", {})
179+
callback_id = view.get("callback_id")
176180
state_values = view.get("state", {}).get("values", {})
177181

178-
draft_content = (
179-
state_values.get("block_draft_content", {})
180-
.get("input_draft_content", {})
181-
.get("value", "")
182-
)
183-
platform = (
184-
state_values.get("block_platform_select", {})
185-
.get("input_platform_select", {})
186-
.get("selected_option", {})
187-
.get("value", "telegram")
188-
)
182+
# --- СЦЕНАРІЙ 1: Генерація нового драфту ---
183+
if callback_id == "modal_generate_draft":
184+
channel_id = view.get("private_metadata") # Дістаємо збережений канал
185+
topic = (
186+
state_values.get("block_topic_input", {})
187+
.get("input_topic", {})
188+
.get("value", "")
189+
.strip()
190+
)
191+
platform = (
192+
state_values.get("block_platform_select", {})
193+
.get("input_platform_select", {})
194+
.get("selected_option", {})
195+
.get("value", "telegram")
196+
)
189197

190-
logger.info("slack_modal_submitted", user_id=user_id, platform=platform)
198+
logger.info(
199+
"slack_generation_modal_submitted",
200+
user_id=user_id,
201+
topic=topic,
202+
platform=platform,
203+
)
191204

192-
await publish_post_task.kiq(
193-
post_id="temp_id", platform=platform, content=draft_content
194-
)
205+
# Запускаємо генерацію
206+
await generate_draft_task.kiq( # type: ignore[call-overload]
207+
topic=topic, platform=platform, user_id=user_id, channel_id=channel_id
208+
)
195209

196-
return Response(
197-
content=json.dumps({"response_action": "clear"}),
198-
media_type="application/json",
199-
status_code=200,
210+
# Відправляємо підтвердження в канал
211+
async with httpx.AsyncClient() as client:
212+
await client.post(
213+
"https://slack.com/api/chat.postMessage",
214+
headers=headers,
215+
json={
216+
"channel": channel_id,
217+
"text": SLACK_UI["cmd_accepted"].format(
218+
topic=topic, platform=platform
219+
),
220+
},
221+
)
222+
223+
return Response(
224+
content=json.dumps({"response_action": "clear"}),
225+
media_type="application/json",
226+
status_code=200,
227+
)
228+
229+
# --- СЦЕНАРІЙ 2: Збереження відредагованого драфту ---
230+
elif callback_id == "modal_edit_draft":
231+
draft_content = (
232+
state_values.get("block_draft_content", {})
233+
.get("input_draft_content", {})
234+
.get("value", "")
235+
)
236+
platform = (
237+
state_values.get("block_platform_select", {})
238+
.get("input_platform_select", {})
239+
.get("selected_option", {})
240+
.get("value", "telegram")
241+
)
242+
243+
logger.info(
244+
"slack_edit_modal_submitted", user_id=user_id, platform=platform
245+
)
246+
await publish_post_task.kiq(
247+
post_id="temp_id", platform=platform, content=draft_content
248+
)
249+
250+
return Response(
251+
content=json.dumps({"response_action": "clear"}),
252+
media_type="application/json",
253+
status_code=200,
254+
)
255+
256+
return Response(status_code=200)
257+
258+
259+
@router.post("/events")
260+
async def slack_events(request: Request):
261+
"""Обробка Events API (наприклад, відкриття вкладки Home)."""
262+
data = await request.json()
263+
264+
# 1. Підтвердження URL для Slack (виконується один раз при налаштуванні)
265+
if data.get("type") == "url_verification":
266+
return {"challenge": data.get("challenge")}
267+
268+
event = data.get("event", {})
269+
user_id = event.get("user")
270+
271+
# 2. Коли користувач відкриває вкладку Home — малюємо йому дашборд
272+
if event.get("type") == "app_home_opened":
273+
slack_token = (
274+
settings.SLACK_BOT_TOKEN.get_secret_value()
275+
if settings.SLACK_BOT_TOKEN
276+
else ""
200277
)
278+
async with httpx.AsyncClient() as client:
279+
await client.post(
280+
"https://slack.com/api/views.publish",
281+
headers={"Authorization": f"Bearer {slack_token}"},
282+
json={"user_id": user_id, "view": build_app_home()},
283+
)
201284

202285
return Response(status_code=200)

backend/config/lexicon.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,14 @@
3232
"modal_platform_label": "Платформа",
3333
"interact_regenerate_text": "⏳ Перегенерація...",
3434
"interact_regenerate_section": "⏳ *Перегенерація...*\nНовий варіант скоро з'явиться.",
35+
# --- Generation Modal ---
36+
"gen_modal_title": "Нова генерація",
37+
"gen_modal_submit": "Згенерувати",
38+
"gen_modal_topic_label": "Про що напишемо?",
39+
"gen_modal_topic_placeholder": "Наприклад: Вплив кави на тривожність",
40+
"gen_modal_platform_label": "Оберіть платформу",
41+
# --- App Home ---
42+
"home_welcome": "Вітаю у Seratonin Script! 🧠",
43+
"home_description": "Це твій центр керування медичним контентом. Натисни кнопку нижче, щоб почати.",
44+
"home_btn_create": "✨ Створити новий пост",
3545
}

slack_app/utils/block_builder.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ def build_draft_card(topic: str, draft: str, user_id: str) -> list[dict[str, Any
7878
]
7979

8080

81-
def build_approval_modal(topic: str, draft: str, platform: str = "telegram") -> dict[str, Any]:
81+
def build_approval_modal(
82+
topic: str, draft: str, platform: str = "telegram"
83+
) -> dict[str, Any]:
8284
"""Генерує модальне вікно для редагування тексту перед публікацією."""
8385
return {
8486
"type": "modal",
@@ -144,3 +146,115 @@ def build_approval_modal(topic: str, draft: str, platform: str = "telegram") ->
144146
},
145147
],
146148
}
149+
150+
151+
def build_generation_modal(channel_id: str) -> dict[str, Any]:
152+
"""Генерує стартове модальне вікно для вводу теми та платформи."""
153+
return {
154+
"type": "modal",
155+
"callback_id": "modal_generate_draft",
156+
"private_metadata": channel_id,
157+
"title": {
158+
"type": "plain_text",
159+
"text": SLACK_UI["gen_modal_title"],
160+
"emoji": True,
161+
},
162+
"submit": {
163+
"type": "plain_text",
164+
"text": SLACK_UI["gen_modal_submit"],
165+
"emoji": True,
166+
},
167+
"close": {
168+
"type": "plain_text",
169+
"text": SLACK_UI["modal_cancel"],
170+
"emoji": True,
171+
},
172+
"blocks": [
173+
{
174+
"type": "input",
175+
"block_id": "block_topic_input",
176+
"element": {
177+
"type": "plain_text_input",
178+
"action_id": "input_topic",
179+
"multiline": False,
180+
"placeholder": {
181+
"type": "plain_text",
182+
"text": SLACK_UI["gen_modal_topic_placeholder"],
183+
},
184+
},
185+
"label": {
186+
"type": "plain_text",
187+
"text": SLACK_UI["gen_modal_topic_label"],
188+
"emoji": True,
189+
},
190+
},
191+
{
192+
"type": "input",
193+
"block_id": "block_platform_select",
194+
"element": {
195+
"type": "static_select",
196+
"action_id": "input_platform_select",
197+
"initial_option": {
198+
"text": {"type": "plain_text", "text": "Telegram"},
199+
"value": "telegram",
200+
},
201+
"options": [
202+
{
203+
"text": {"type": "plain_text", "text": "Telegram"},
204+
"value": "telegram",
205+
},
206+
{
207+
"text": {"type": "plain_text", "text": "Twitter"},
208+
"value": "twitter",
209+
},
210+
{
211+
"text": {"type": "plain_text", "text": "Threads"},
212+
"value": "threads",
213+
},
214+
],
215+
},
216+
"label": {
217+
"type": "plain_text",
218+
"text": SLACK_UI["gen_modal_platform_label"],
219+
"emoji": True,
220+
},
221+
},
222+
],
223+
}
224+
225+
226+
def build_app_home() -> dict[str, Any]:
227+
"""Генерує інтерфейс вкладки Home."""
228+
return {
229+
"type": "home",
230+
"blocks": [
231+
{
232+
"type": "header",
233+
"text": {
234+
"type": "plain_text",
235+
"text": SLACK_UI["home_welcome"],
236+
"emoji": True,
237+
},
238+
},
239+
{
240+
"type": "section",
241+
"text": {"type": "mrkdwn", "text": SLACK_UI["home_description"]},
242+
},
243+
{"type": "divider"},
244+
{
245+
"type": "actions",
246+
"elements": [
247+
{
248+
"type": "button",
249+
"text": {
250+
"type": "plain_text",
251+
"text": SLACK_UI["home_btn_create"],
252+
"emoji": True,
253+
},
254+
"style": "primary",
255+
"action_id": "action_open_generation_modal", # Та сама логіка, що й для команди
256+
}
257+
],
258+
},
259+
],
260+
}

0 commit comments

Comments
 (0)