chainlit 1.1.0rc1__py3-none-any.whl → 1.1.200__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.

Potentially problematic release.


This version of chainlit might be problematic. Click here for more details.

@@ -22,7 +22,7 @@
22
22
  <script>
23
23
  const global = globalThis;
24
24
  </script>
25
- <script type="module" crossorigin src="/assets/index-032fca02.js"></script>
25
+ <script type="module" crossorigin src="/assets/index-d9bad4f1.js"></script>
26
26
  <link rel="stylesheet" href="/assets/index-d088547c.css">
27
27
  </head>
28
28
  <body>
chainlit/message.py CHANGED
@@ -206,7 +206,7 @@ class Message(MessageBase):
206
206
  def __init__(
207
207
  self,
208
208
  content: Union[str, Dict],
209
- author: str = config.ui.name,
209
+ author: Optional[str] = None,
210
210
  language: Optional[str] = None,
211
211
  actions: Optional[List[Action]] = None,
212
212
  elements: Optional[List[ElementBased]] = None,
@@ -243,7 +243,7 @@ class Message(MessageBase):
243
243
  self.metadata = metadata
244
244
  self.tags = tags
245
245
 
246
- self.author = author
246
+ self.author = author or config.ui.name
247
247
  self.type = type
248
248
  self.actions = actions if actions is not None else []
249
249
  self.elements = elements if elements is not None else []
chainlit/server.py CHANGED
@@ -119,16 +119,27 @@ async def lifespan(app: FastAPI):
119
119
 
120
120
  watch_task = asyncio.create_task(watch_files_for_changes())
121
121
 
122
+ discord_task = None
123
+
124
+ if discord_bot_token := os.environ.get("DISCORD_BOT_TOKEN"):
125
+ from chainlit.discord.app import client
126
+
127
+ discord_task = asyncio.create_task(client.start(discord_bot_token))
128
+
122
129
  try:
123
130
  yield
124
131
  finally:
125
- if watch_task:
126
- try:
132
+ try:
133
+ if watch_task:
127
134
  stop_event.set()
128
135
  watch_task.cancel()
129
136
  await watch_task
130
- except asyncio.exceptions.CancelledError:
131
- pass
137
+
138
+ if discord_task:
139
+ discord_task.cancel()
140
+ await discord_task
141
+ except asyncio.exceptions.CancelledError:
142
+ pass
132
143
 
133
144
  if FILES_DIRECTORY.is_dir():
134
145
  shutil.rmtree(FILES_DIRECTORY)
@@ -195,6 +206,18 @@ socket = SocketManager(
195
206
  )
196
207
 
197
208
 
209
+ # -------------------------------------------------------------------------------
210
+ # SLACK HANDLER
211
+ # -------------------------------------------------------------------------------
212
+
213
+ if os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_SIGNING_SECRET"):
214
+ from chainlit.slack.app import slack_app_handler
215
+
216
+ @app.post("/slack/events")
217
+ async def endpoint(req: Request):
218
+ return await slack_app_handler.handle(req)
219
+
220
+
198
221
  # -------------------------------------------------------------------------------
199
222
  # HTTP HANDLERS
200
223
  # -------------------------------------------------------------------------------
@@ -211,14 +234,16 @@ def get_html_template():
211
234
  CSS_PLACEHOLDER = "<!-- CSS INJECTION PLACEHOLDER -->"
212
235
 
213
236
  default_url = "https://github.com/Chainlit/chainlit"
237
+ default_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png"
214
238
  url = config.ui.github or default_url
239
+ meta_image_url = config.ui.custom_meta_image_url or default_meta_image_url
215
240
 
216
241
  tags = f"""<title>{config.ui.name}</title>
217
242
  <meta name="description" content="{config.ui.description}">
218
243
  <meta property="og:type" content="website">
219
244
  <meta property="og:title" content="{config.ui.name}">
220
245
  <meta property="og:description" content="{config.ui.description}">
221
- <meta property="og:image" content="https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png">
246
+ <meta property="og:image" content="{meta_image_url}">
222
247
  <meta property="og:url" content="{url}">"""
223
248
 
224
249
  js = f"""<script>{f"window.theme = {json.dumps(config.ui.theme.to_dict())}; " if config.ui.theme else ""}</script>"""
chainlit/session.py CHANGED
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
24
24
  from chainlit.types import FileDict, FileReference
25
25
  from chainlit.user import PersistedUser, User
26
26
 
27
- ClientType = Literal["app", "copilot", "teams", "slack"]
27
+ ClientType = Literal["webapp", "copilot", "teams", "slack", "discord"]
28
28
 
29
29
 
30
30
  class JSONEncoderIgnoreNonSerializable(json.JSONEncoder):
@@ -35,11 +35,21 @@ class JSONEncoderIgnoreNonSerializable(json.JSONEncoder):
35
35
  return None
36
36
 
37
37
 
38
- def clean_metadata(metadata: Dict):
39
- return json.loads(
38
+
39
+ def clean_metadata(metadata: Dict, max_size: int = 1048576):
40
+ cleaned_metadata = json.loads(
40
41
  json.dumps(metadata, cls=JSONEncoderIgnoreNonSerializable, ensure_ascii=False)
41
42
  )
42
43
 
44
+ metadata_size = len(json.dumps(cleaned_metadata).encode('utf-8'))
45
+ if metadata_size > max_size:
46
+ # Redact the metadata if it exceeds the maximum size
47
+ cleaned_metadata = {
48
+ 'message': f'Metadata size exceeds the limit of {max_size} bytes. Redacted.'
49
+ }
50
+
51
+ return cleaned_metadata
52
+
43
53
 
44
54
  class BaseSession:
45
55
  """Base object."""
@@ -80,18 +90,66 @@ class BaseSession:
80
90
  self.chat_profile = chat_profile
81
91
  self.http_referer = http_referer
82
92
 
93
+ self.files = {} # type: Dict[str, "FileDict"]
94
+
83
95
  self.id = id
84
96
 
85
97
  self.chat_settings: Dict[str, Any] = {}
86
98
 
99
+ @property
100
+ def files_dir(self):
101
+ from chainlit.config import FILES_DIRECTORY
102
+
103
+ return FILES_DIRECTORY / self.id
104
+
87
105
  async def persist_file(
88
106
  self,
89
107
  name: str,
90
108
  mime: str,
91
109
  path: Optional[str] = None,
92
110
  content: Optional[Union[bytes, str]] = None,
93
- ):
94
- return None
111
+ ) -> "FileReference":
112
+ if not path and not content:
113
+ raise ValueError(
114
+ "Either path or content must be provided to persist a file"
115
+ )
116
+
117
+ self.files_dir.mkdir(exist_ok=True)
118
+
119
+ file_id = str(uuid.uuid4())
120
+
121
+ file_path = self.files_dir / file_id
122
+
123
+ file_extension = mimetypes.guess_extension(mime)
124
+
125
+ if file_extension:
126
+ file_path = file_path.with_suffix(file_extension)
127
+
128
+ if path:
129
+ # Copy the file from the given path
130
+ async with aiofiles.open(path, "rb") as src, aiofiles.open(
131
+ file_path, "wb"
132
+ ) as dst:
133
+ await dst.write(await src.read())
134
+ elif content:
135
+ # Write the provided content to the file
136
+ async with aiofiles.open(file_path, "wb") as buffer:
137
+ if isinstance(content, str):
138
+ content = content.encode("utf-8")
139
+ await buffer.write(content)
140
+
141
+ # Get the file size
142
+ file_size = file_path.stat().st_size
143
+ # Store the file content in memory
144
+ self.files[file_id] = {
145
+ "id": file_id,
146
+ "path": file_path,
147
+ "name": name,
148
+ "type": mime,
149
+ "size": file_size,
150
+ }
151
+
152
+ return {"id": file_id}
95
153
 
96
154
  def to_persistable(self) -> Dict:
97
155
  from chainlit.user_session import user_sessions
@@ -99,6 +157,8 @@ class BaseSession:
99
157
  user_session = user_sessions.get(self.id) or {} # type: Dict
100
158
  user_session["chat_settings"] = self.chat_settings
101
159
  user_session["chat_profile"] = self.chat_profile
160
+ user_session["http_referer"] = self.http_referer
161
+ user_session["client_type"] = self.client_type
102
162
  metadata = clean_metadata(user_session)
103
163
  return metadata
104
164
 
@@ -134,6 +194,11 @@ class HTTPSession(BaseSession):
134
194
  http_referer=http_referer,
135
195
  )
136
196
 
197
+ def delete(self):
198
+ """Delete the session."""
199
+ if self.files_dir.is_dir():
200
+ shutil.rmtree(self.files_dir)
201
+
137
202
 
138
203
  class WebsocketSession(BaseSession):
139
204
  """Internal web socket session object.
@@ -147,6 +212,8 @@ class WebsocketSession(BaseSession):
147
212
  socket id for convenience.
148
213
  """
149
214
 
215
+ to_clear: bool = False
216
+
150
217
  def __init__(
151
218
  self,
152
219
  # Id from the session cookie
@@ -194,68 +261,12 @@ class WebsocketSession(BaseSession):
194
261
  self.restored = False
195
262
 
196
263
  self.thread_queues = {} # type: Dict[str, Deque[Callable]]
197
- self.files = {} # type: Dict[str, "FileDict"]
198
264
 
199
265
  ws_sessions_id[self.id] = self
200
266
  ws_sessions_sid[socket_id] = self
201
267
 
202
268
  self.languages = languages
203
269
 
204
- @property
205
- def files_dir(self):
206
- from chainlit.config import FILES_DIRECTORY
207
-
208
- return FILES_DIRECTORY / self.id
209
-
210
- async def persist_file(
211
- self,
212
- name: str,
213
- mime: str,
214
- path: Optional[str] = None,
215
- content: Optional[Union[bytes, str]] = None,
216
- ) -> "FileReference":
217
- if not path and not content:
218
- raise ValueError(
219
- "Either path or content must be provided to persist a file"
220
- )
221
-
222
- self.files_dir.mkdir(exist_ok=True)
223
-
224
- file_id = str(uuid.uuid4())
225
-
226
- file_path = self.files_dir / file_id
227
-
228
- file_extension = mimetypes.guess_extension(mime)
229
-
230
- if file_extension:
231
- file_path = file_path.with_suffix(file_extension)
232
-
233
- if path:
234
- # Copy the file from the given path
235
- async with aiofiles.open(path, "rb") as src, aiofiles.open(
236
- file_path, "wb"
237
- ) as dst:
238
- await dst.write(await src.read())
239
- elif content:
240
- # Write the provided content to the file
241
- async with aiofiles.open(file_path, "wb") as buffer:
242
- if isinstance(content, str):
243
- content = content.encode("utf-8")
244
- await buffer.write(content)
245
-
246
- # Get the file size
247
- file_size = file_path.stat().st_size
248
- # Store the file content in memory
249
- self.files[file_id] = {
250
- "id": file_id,
251
- "path": file_path,
252
- "name": name,
253
- "type": mime,
254
- "size": file_size,
255
- }
256
-
257
- return {"id": file_id}
258
-
259
270
  def restore(self, new_socket_id: str):
260
271
  """Associate a new socket id to the session."""
261
272
  ws_sessions_sid.pop(self.socket_id, None)
@@ -0,0 +1,6 @@
1
+ try:
2
+ import slack_bolt
3
+ except ModuleNotFoundError:
4
+ raise ValueError(
5
+ "The slack_bolt package is required to integrate Chainlit with a Slack app. Run `pip install slack_bolt --upgrade`"
6
+ )
chainlit/slack/app.py ADDED
@@ -0,0 +1,390 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import uuid
5
+ from functools import partial
6
+ from typing import Dict, List, Optional, Union
7
+
8
+ import httpx
9
+ from chainlit.config import config
10
+ from chainlit.context import ChainlitContext, HTTPSession, context_var
11
+ from chainlit.data import get_data_layer
12
+ from chainlit.element import Element, ElementDict
13
+ from chainlit.emitter import BaseChainlitEmitter
14
+ from chainlit.logger import logger
15
+ from chainlit.message import Message, StepDict
16
+ from chainlit.telemetry import trace
17
+ from chainlit.types import Feedback
18
+ from chainlit.user import PersistedUser, User
19
+ from chainlit.user_session import user_session
20
+ from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
21
+ from slack_bolt.async_app import AsyncApp
22
+
23
+
24
+ class SlackEmitter(BaseChainlitEmitter):
25
+ def __init__(
26
+ self,
27
+ session: HTTPSession,
28
+ app: AsyncApp,
29
+ channel_id: str,
30
+ say,
31
+ enabled=False,
32
+ thread_ts: Optional[str] = None,
33
+ ):
34
+ super().__init__(session)
35
+ self.app = app
36
+ self.channel_id = channel_id
37
+ self.say = say
38
+ self.enabled = enabled
39
+ self.thread_ts = thread_ts
40
+
41
+ async def send_element(self, element_dict: ElementDict):
42
+ if not self.enabled or element_dict.get("display") != "inline":
43
+ return
44
+
45
+ persisted_file = self.session.files.get(element_dict.get("chainlitKey") or "")
46
+ file: Optional[Union[bytes, str]] = None
47
+
48
+ if persisted_file:
49
+ file = str(persisted_file["path"])
50
+ elif file_url := element_dict.get("url"):
51
+ async with httpx.AsyncClient() as client:
52
+ response = await client.get(file_url)
53
+ if response.status_code == 200:
54
+ file = response.content
55
+
56
+ if not file:
57
+ return
58
+
59
+ await self.app.client.files_upload_v2(
60
+ channel=self.channel_id,
61
+ thread_ts=self.thread_ts,
62
+ file=file,
63
+ title=element_dict.get("name"),
64
+ )
65
+
66
+ async def send_step(self, step_dict: StepDict):
67
+ if not self.enabled:
68
+ return
69
+
70
+ step_type = step_dict.get("type")
71
+ is_message = step_type in [
72
+ "user_message",
73
+ "assistant_message",
74
+ "system_message",
75
+ ]
76
+ is_chain_of_thought = bool(step_dict.get("parentId"))
77
+ is_empty_output = not step_dict.get("output")
78
+
79
+ if is_chain_of_thought or is_empty_output or not is_message:
80
+ return
81
+
82
+ enable_feedback = not step_dict.get("disableFeedback") and get_data_layer()
83
+ blocks: List[Dict] = [
84
+ {
85
+ "type": "section",
86
+ "text": {"type": "mrkdwn", "text": step_dict["output"]},
87
+ }
88
+ ]
89
+ if enable_feedback:
90
+ blocks.append(
91
+ {
92
+ "type": "actions",
93
+ "elements": [
94
+ {
95
+ "action_id": "thumbdown",
96
+ "type": "button",
97
+ "text": {
98
+ "type": "plain_text",
99
+ "emoji": True,
100
+ "text": ":thumbsdown:",
101
+ },
102
+ "value": step_dict.get("id"),
103
+ },
104
+ {
105
+ "action_id": "thumbup",
106
+ "type": "button",
107
+ "text": {
108
+ "type": "plain_text",
109
+ "emoji": True,
110
+ "text": ":thumbsup:",
111
+ },
112
+ "value": step_dict.get("id"),
113
+ },
114
+ ],
115
+ }
116
+ )
117
+ await self.say(
118
+ text=step_dict["output"], blocks=blocks, thread_ts=self.thread_ts
119
+ )
120
+
121
+ async def update_step(self, step_dict: StepDict):
122
+ if not self.enabled:
123
+ return
124
+
125
+ await self.send_step(step_dict)
126
+
127
+
128
+ slack_app = AsyncApp(
129
+ token=os.environ.get("SLACK_BOT_TOKEN"),
130
+ signing_secret=os.environ.get("SLACK_SIGNING_SECRET"),
131
+ )
132
+
133
+
134
+ @trace
135
+ def init_slack_context(
136
+ session: HTTPSession,
137
+ slack_channel_id: str,
138
+ event,
139
+ say,
140
+ thread_ts: Optional[str] = None,
141
+ ) -> ChainlitContext:
142
+ emitter = SlackEmitter(
143
+ session=session,
144
+ app=slack_app,
145
+ channel_id=slack_channel_id,
146
+ say=say,
147
+ thread_ts=thread_ts,
148
+ )
149
+ context = ChainlitContext(session=session, emitter=emitter)
150
+ context_var.set(context)
151
+ user_session.set("slack_event", event)
152
+ user_session.set(
153
+ "fetch_slack_message_history",
154
+ partial(
155
+ fetch_message_history, channel_id=slack_channel_id, thread_ts=thread_ts
156
+ ),
157
+ )
158
+ return context
159
+
160
+
161
+ slack_app_handler = AsyncSlackRequestHandler(slack_app)
162
+
163
+ users_by_slack_id: Dict[str, Union[User, PersistedUser]] = {}
164
+
165
+ USER_PREFIX = "slack_"
166
+
167
+
168
+ def clean_content(message: str):
169
+ cleaned_text = re.sub(r"<@[\w]+>", "", message).strip()
170
+ return cleaned_text
171
+
172
+
173
+ async def get_user(slack_user_id: str):
174
+ if slack_user_id in users_by_slack_id:
175
+ return users_by_slack_id[slack_user_id]
176
+
177
+ slack_user = await slack_app.client.users_info(user=slack_user_id)
178
+ slack_user_profile = slack_user["user"]["profile"]
179
+
180
+ user_email = slack_user_profile.get("email")
181
+ user = User(identifier=USER_PREFIX + user_email, metadata=slack_user_profile)
182
+
183
+ users_by_slack_id[slack_user_id] = user
184
+
185
+ if data_layer := get_data_layer():
186
+ try:
187
+ persisted_user = await data_layer.create_user(user)
188
+ if persisted_user:
189
+ users_by_slack_id[slack_user_id] = persisted_user
190
+ except Exception as e:
191
+ logger.error(f"Error creating user: {e}")
192
+
193
+ return users_by_slack_id[slack_user_id]
194
+
195
+
196
+ async def fetch_message_history(
197
+ channel_id: str, thread_ts: Optional[str] = None, limit=30
198
+ ):
199
+ if not thread_ts:
200
+ result = await slack_app.client.conversations_history(
201
+ channel=channel_id, limit=limit
202
+ )
203
+ else:
204
+ result = await slack_app.client.conversations_replies(
205
+ channel=channel_id, ts=thread_ts, limit=limit
206
+ )
207
+ if result["ok"]:
208
+ messages = result["messages"]
209
+ return messages
210
+ else:
211
+ raise Exception(f"Failed to fetch messages: {result['error']}")
212
+
213
+
214
+ async def download_slack_file(url, token):
215
+ headers = {"Authorization": f"Bearer {token}"}
216
+ async with httpx.AsyncClient() as client:
217
+ response = await client.get(url, headers=headers)
218
+ if response.status_code == 200:
219
+ return response.content
220
+ else:
221
+ return None
222
+
223
+
224
+ async def download_slack_files(session: HTTPSession, files, token):
225
+ download_coros = [
226
+ download_slack_file(file.get("url_private"), token) for file in files
227
+ ]
228
+ file_bytes_list = await asyncio.gather(*download_coros)
229
+ file_refs = []
230
+ for idx, file_bytes in enumerate(file_bytes_list):
231
+ if file_bytes:
232
+ name = files[idx].get("name")
233
+ mime_type = files[idx].get("mimetype")
234
+ file_ref = await session.persist_file(
235
+ name=name, mime=mime_type, content=file_bytes
236
+ )
237
+ file_refs.append(file_ref)
238
+
239
+ files_dicts = [
240
+ session.files[file["id"]] for file in file_refs if file["id"] in session.files
241
+ ]
242
+
243
+ file_elements = [Element.from_dict(file_dict) for file_dict in files_dicts]
244
+
245
+ return file_elements
246
+
247
+
248
+ async def process_slack_message(
249
+ event,
250
+ say,
251
+ thread_name: Optional[str] = None,
252
+ bind_thread_to_user=False,
253
+ thread_ts: Optional[str] = None,
254
+ ):
255
+ user = await get_user(event["user"])
256
+
257
+ channel_id = event["channel"]
258
+ thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts or channel_id))
259
+
260
+ text = event.get("text")
261
+ slack_files = event.get("files", [])
262
+
263
+ session_id = str(uuid.uuid4())
264
+ session = HTTPSession(
265
+ id=session_id,
266
+ thread_id=thread_id,
267
+ user=user,
268
+ client_type="slack",
269
+ )
270
+
271
+ ctx = init_slack_context(
272
+ session=session,
273
+ slack_channel_id=channel_id,
274
+ event=event,
275
+ say=say,
276
+ thread_ts=thread_ts,
277
+ )
278
+
279
+ file_elements = await download_slack_files(
280
+ session, slack_files, slack_app.client.token
281
+ )
282
+
283
+ msg = Message(
284
+ content=clean_content(text),
285
+ elements=file_elements,
286
+ type="user_message",
287
+ author=user.metadata.get("real_name"),
288
+ )
289
+
290
+ await msg.send()
291
+
292
+ ctx.emitter.enabled = True
293
+
294
+ if on_chat_start := config.code.on_chat_start:
295
+ await on_chat_start()
296
+
297
+ if on_message := config.code.on_message:
298
+ await on_message(msg)
299
+
300
+ if on_chat_end := config.code.on_chat_end:
301
+ await on_chat_end()
302
+
303
+ if data_layer := get_data_layer():
304
+ user_id = None
305
+ if isinstance(user, PersistedUser):
306
+ user_id = user.id if bind_thread_to_user else None
307
+
308
+ try:
309
+ await data_layer.update_thread(
310
+ thread_id=thread_id,
311
+ name=thread_name or msg.content,
312
+ metadata=ctx.session.to_persistable(),
313
+ user_id=user_id,
314
+ )
315
+ except Exception as e:
316
+ logger.error(f"Error updating thread: {e}")
317
+
318
+ ctx.session.delete()
319
+
320
+
321
+ @slack_app.event("app_home_opened")
322
+ async def handle_app_home_opened(event, say):
323
+ pass
324
+
325
+
326
+ @slack_app.event("app_mention")
327
+ async def handle_app_mentions(event, say):
328
+ thread_ts = event.get("thread_ts", event["ts"])
329
+ await process_slack_message(event, say, thread_ts=thread_ts)
330
+
331
+
332
+ @slack_app.event("message")
333
+ async def handle_message(message, say):
334
+ user = await get_user(message["user"])
335
+ thread_name = f"{user.identifier} Slack DM"
336
+ await process_slack_message(message, say, thread_name)
337
+
338
+
339
+ @slack_app.block_action("thumbdown")
340
+ async def thumb_down(ack, context, body):
341
+ await ack()
342
+ step_id = body["actions"][0]["value"]
343
+
344
+ if data_layer := get_data_layer():
345
+ thread_id = context_var.get().session.thread_id
346
+ feedback = Feedback(forId=step_id, threadId=thread_id, value=0)
347
+ await data_layer.upsert_feedback(feedback)
348
+
349
+ text = body["message"]["text"]
350
+ blocks = body["message"]["blocks"]
351
+ updated_blocks = [block for block in blocks if block["type"] != "actions"]
352
+ updated_blocks.append(
353
+ {
354
+ "type": "section",
355
+ "text": {"type": "mrkdwn", "text": ":thumbsdown: Feedback received."},
356
+ }
357
+ )
358
+ await context.client.chat_update(
359
+ channel=body["channel"]["id"],
360
+ ts=body["container"]["message_ts"],
361
+ text=text,
362
+ blocks=updated_blocks,
363
+ )
364
+
365
+
366
+ @slack_app.block_action("thumbup")
367
+ async def thumb_up(ack, context, body):
368
+ await ack()
369
+ step_id = body["actions"][0]["value"]
370
+
371
+ if data_layer := get_data_layer():
372
+ thread_id = context_var.get().session.thread_id
373
+ feedback = Feedback(forId=step_id, threadId=thread_id, value=1)
374
+ await data_layer.upsert_feedback(feedback)
375
+
376
+ text = body["message"]["text"]
377
+ blocks = body["message"]["blocks"]
378
+ updated_blocks = [block for block in blocks if block["type"] != "actions"]
379
+ updated_blocks.append(
380
+ {
381
+ "type": "section",
382
+ "text": {"type": "mrkdwn", "text": ":thumbsup: Feedback received."},
383
+ }
384
+ )
385
+ await context.client.chat_update(
386
+ channel=body["channel"]["id"],
387
+ ts=body["container"]["message_ts"],
388
+ text=text,
389
+ blocks=updated_blocks,
390
+ )