slide-narrator 5.5.0__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.
- narrator/__init__.py +18 -0
- narrator/database/__init__.py +8 -0
- narrator/database/cli.py +222 -0
- narrator/database/migrations/__init__.py +6 -0
- narrator/database/models.py +70 -0
- narrator/database/storage_backend.py +624 -0
- narrator/database/thread_store.py +282 -0
- narrator/models/__init__.py +9 -0
- narrator/models/attachment.py +386 -0
- narrator/models/message.py +512 -0
- narrator/models/thread.py +467 -0
- narrator/storage/__init__.py +7 -0
- narrator/storage/file_store.py +536 -0
- narrator/utils/__init__.py +9 -0
- narrator/utils/logging.py +52 -0
- slide_narrator-5.5.0.dist-info/METADATA +558 -0
- slide_narrator-5.5.0.dist-info/RECORD +20 -0
- slide_narrator-5.5.0.dist-info/WHEEL +4 -0
- slide_narrator-5.5.0.dist-info/entry_points.txt +2 -0
- slide_narrator-5.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
from typing import List, Dict, Optional, Literal, Any
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
from narrator.models.message import Message
|
|
5
|
+
from narrator.storage.file_store import FileStore
|
|
6
|
+
import uuid
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
class Thread(BaseModel):
|
|
10
|
+
"""Represents a thread containing multiple messages"""
|
|
11
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
12
|
+
title: Optional[str] = Field(default="Untitled Thread")
|
|
13
|
+
messages: List[Message] = Field(default_factory=list)
|
|
14
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
15
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
16
|
+
attributes: Dict = Field(default_factory=dict)
|
|
17
|
+
platforms: Dict[str, Dict[str, str]] = Field(
|
|
18
|
+
default_factory=dict,
|
|
19
|
+
description="References to where this thread exists on external platforms. Maps platform name to platform-specific identifiers."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
model_config = {
|
|
23
|
+
"json_schema_extra": {
|
|
24
|
+
"examples": [
|
|
25
|
+
{
|
|
26
|
+
"id": "thread-123",
|
|
27
|
+
"title": "Example Thread",
|
|
28
|
+
"messages": [],
|
|
29
|
+
"created_at": "2024-02-07T00:00:00+00:00",
|
|
30
|
+
"updated_at": "2024-02-07T00:00:00+00:00",
|
|
31
|
+
"attributes": {},
|
|
32
|
+
"platforms": {
|
|
33
|
+
"slack": {
|
|
34
|
+
"channel": "C123",
|
|
35
|
+
"thread_ts": "1234567890.123"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@field_validator("created_at", "updated_at", mode="before")
|
|
44
|
+
def ensure_timezone(cls, value: datetime) -> datetime:
|
|
45
|
+
"""Ensure all datetime fields are timezone-aware UTC"""
|
|
46
|
+
if value.tzinfo is None:
|
|
47
|
+
return value.replace(tzinfo=timezone.utc)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
def model_dump(self, mode: str = "json") -> Dict[str, Any]:
|
|
51
|
+
"""Convert thread to a dictionary suitable for JSON serialization
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
mode: Serialization mode, either "json" or "python".
|
|
55
|
+
"json" converts datetimes to ISO strings (default).
|
|
56
|
+
"python" keeps datetimes as datetime objects.
|
|
57
|
+
"""
|
|
58
|
+
return {
|
|
59
|
+
"id": self.id,
|
|
60
|
+
"title": self.title,
|
|
61
|
+
"messages": [msg.model_dump(mode=mode) for msg in self.messages],
|
|
62
|
+
"created_at": self.created_at.isoformat() if mode == "json" else self.created_at,
|
|
63
|
+
"updated_at": self.updated_at.isoformat() if mode == "json" else self.updated_at,
|
|
64
|
+
"attributes": self.attributes,
|
|
65
|
+
"platforms": self.platforms
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def add_message(self, message: Message, same_turn: bool = False) -> None:
|
|
69
|
+
"""Add a new message to the thread and update analytics
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
message: The message to add
|
|
73
|
+
same_turn: If True, assign the same turn as the last message (for grouping related messages)
|
|
74
|
+
"""
|
|
75
|
+
# Set message sequence - system messages always get 0, others get next available number starting at 1
|
|
76
|
+
if message.role == "system":
|
|
77
|
+
message.sequence = 0
|
|
78
|
+
message.turn = 0 # System messages always get turn 0
|
|
79
|
+
# Insert at beginning to maintain system message first
|
|
80
|
+
self.messages.insert(0, message)
|
|
81
|
+
else:
|
|
82
|
+
# Find highest sequence number and increment
|
|
83
|
+
max_sequence = max((m.sequence for m in self.messages if m.role != "system"), default=0)
|
|
84
|
+
message.sequence = max_sequence + 1
|
|
85
|
+
self.messages.append(message)
|
|
86
|
+
|
|
87
|
+
# Handle turn assignment for non-system messages
|
|
88
|
+
if message.role != "system":
|
|
89
|
+
if same_turn and self.messages and len(self.messages) > 1:
|
|
90
|
+
# Use same turn as last message (excluding the one we just added)
|
|
91
|
+
last_message = self.messages[-2] # Get second-to-last since we just appended
|
|
92
|
+
if last_message.role != "system":
|
|
93
|
+
message.turn = last_message.turn
|
|
94
|
+
else:
|
|
95
|
+
# If last message was system, get next turn
|
|
96
|
+
max_turn = max((m.turn for m in self.messages if m.turn is not None and m.role != "system"), default=0)
|
|
97
|
+
message.turn = max_turn + 1
|
|
98
|
+
else:
|
|
99
|
+
# Get next turn number
|
|
100
|
+
max_turn = max((m.turn for m in self.messages if m.turn is not None and m.role != "system"), default=0)
|
|
101
|
+
message.turn = max_turn + 1
|
|
102
|
+
|
|
103
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
104
|
+
|
|
105
|
+
def add_messages_batch(self, messages: List[Message]) -> None:
|
|
106
|
+
"""Add multiple messages as a batch (all get the same turn number)
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
messages: List of messages to add as a group
|
|
110
|
+
"""
|
|
111
|
+
if not messages:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Get the next turn number for all messages in the batch
|
|
115
|
+
max_turn = max((m.turn for m in self.messages if m.turn is not None and m.role != "system"), default=0)
|
|
116
|
+
batch_turn = max_turn + 1
|
|
117
|
+
|
|
118
|
+
# Add each message with the same turn number
|
|
119
|
+
for i, message in enumerate(messages):
|
|
120
|
+
if message.role == "system":
|
|
121
|
+
# System messages handled separately
|
|
122
|
+
message.sequence = 0
|
|
123
|
+
message.turn = 0
|
|
124
|
+
self.messages.insert(0, message)
|
|
125
|
+
else:
|
|
126
|
+
# Set sequence and turn for non-system messages
|
|
127
|
+
max_sequence = max((m.sequence for m in self.messages if m.role != "system"), default=0)
|
|
128
|
+
message.sequence = max_sequence + 1
|
|
129
|
+
message.turn = batch_turn
|
|
130
|
+
self.messages.append(message)
|
|
131
|
+
|
|
132
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
133
|
+
|
|
134
|
+
async def get_messages_for_chat_completion(self, file_store: Optional[FileStore] = None) -> List[Dict[str, Any]]:
|
|
135
|
+
"""Return messages in the format expected by chat completion APIs
|
|
136
|
+
|
|
137
|
+
Note: This excludes system messages as they are injected by agents at completion time.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
file_store: Optional FileStore instance to pass to messages for file URL access
|
|
141
|
+
"""
|
|
142
|
+
# Only include non-system messages from the thread - system messages are injected by agents
|
|
143
|
+
return [msg.to_chat_completion_message(file_store=file_store) for msg in self.messages if msg.role != "system"]
|
|
144
|
+
|
|
145
|
+
def clear_messages(self) -> None:
|
|
146
|
+
"""Clear all messages from the thread"""
|
|
147
|
+
self.messages = []
|
|
148
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
149
|
+
|
|
150
|
+
def get_last_message_by_role(self, role: Literal["user", "assistant", "system", "tool"]) -> Optional[Message]:
|
|
151
|
+
"""Return the last message with the specified role, or None if no messages exist with that role"""
|
|
152
|
+
messages = [m for m in self.messages if m.role == role]
|
|
153
|
+
return messages[-1] if messages else None
|
|
154
|
+
|
|
155
|
+
def generate_title(self) -> str:
|
|
156
|
+
"""Generate a simple title for the thread based on first message"""
|
|
157
|
+
if not self.messages:
|
|
158
|
+
return "Empty Thread"
|
|
159
|
+
|
|
160
|
+
# Use first user message content for title
|
|
161
|
+
user_messages = [msg for msg in self.messages if msg.role == "user"]
|
|
162
|
+
if user_messages:
|
|
163
|
+
first_content = user_messages[0].content
|
|
164
|
+
if isinstance(first_content, str):
|
|
165
|
+
# Take first 50 characters and add ellipsis if needed
|
|
166
|
+
title = first_content[:50]
|
|
167
|
+
if len(first_content) > 50:
|
|
168
|
+
title += "..."
|
|
169
|
+
self.title = title
|
|
170
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
171
|
+
return title
|
|
172
|
+
|
|
173
|
+
return "Untitled Thread"
|
|
174
|
+
|
|
175
|
+
def get_total_tokens(self) -> Dict[str, Any]:
|
|
176
|
+
"""Get total token usage across all messages in the thread
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Dictionary containing:
|
|
180
|
+
- overall: Total token counts across all models
|
|
181
|
+
- by_model: Token counts broken down by model
|
|
182
|
+
"""
|
|
183
|
+
overall = {
|
|
184
|
+
"completion_tokens": 0,
|
|
185
|
+
"prompt_tokens": 0,
|
|
186
|
+
"total_tokens": 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
by_model = {}
|
|
190
|
+
|
|
191
|
+
for message in self.messages:
|
|
192
|
+
metrics = message.metrics
|
|
193
|
+
if not metrics:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Update overall counts
|
|
197
|
+
if "usage" in metrics:
|
|
198
|
+
overall["completion_tokens"] += metrics["usage"].get("completion_tokens", 0)
|
|
199
|
+
overall["prompt_tokens"] += metrics["usage"].get("prompt_tokens", 0)
|
|
200
|
+
overall["total_tokens"] += metrics["usage"].get("total_tokens", 0)
|
|
201
|
+
|
|
202
|
+
# Update per-model counts
|
|
203
|
+
model = metrics.get("model")
|
|
204
|
+
if model:
|
|
205
|
+
if model not in by_model:
|
|
206
|
+
by_model[model] = {
|
|
207
|
+
"completion_tokens": 0,
|
|
208
|
+
"prompt_tokens": 0,
|
|
209
|
+
"total_tokens": 0
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if "usage" in metrics:
|
|
213
|
+
by_model[model]["completion_tokens"] += metrics["usage"].get("completion_tokens", 0)
|
|
214
|
+
by_model[model]["prompt_tokens"] += metrics["usage"].get("prompt_tokens", 0)
|
|
215
|
+
by_model[model]["total_tokens"] += metrics["usage"].get("total_tokens", 0)
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"overall": overall,
|
|
219
|
+
"by_model": by_model
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def get_model_usage(self, model_name: Optional[str] = None) -> Dict[str, Any]:
|
|
223
|
+
"""Get usage statistics for a specific model or all models
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
model_name: Optional name of model to get stats for. If None, returns all models.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Dictionary containing model usage statistics
|
|
230
|
+
"""
|
|
231
|
+
model_usage = {}
|
|
232
|
+
|
|
233
|
+
for message in self.messages:
|
|
234
|
+
metrics = message.metrics
|
|
235
|
+
if not metrics or not metrics.get("model"):
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
model = metrics["model"]
|
|
239
|
+
if model not in model_usage:
|
|
240
|
+
model_usage[model] = {
|
|
241
|
+
"calls": 0,
|
|
242
|
+
"completion_tokens": 0,
|
|
243
|
+
"prompt_tokens": 0,
|
|
244
|
+
"total_tokens": 0
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
model_usage[model]["calls"] += 1
|
|
248
|
+
if "usage" in metrics:
|
|
249
|
+
model_usage[model]["completion_tokens"] += metrics["usage"].get("completion_tokens", 0)
|
|
250
|
+
model_usage[model]["prompt_tokens"] += metrics["usage"].get("prompt_tokens", 0)
|
|
251
|
+
model_usage[model]["total_tokens"] += metrics["usage"].get("total_tokens", 0)
|
|
252
|
+
|
|
253
|
+
if model_name:
|
|
254
|
+
return model_usage.get(model_name, {
|
|
255
|
+
"calls": 0,
|
|
256
|
+
"completion_tokens": 0,
|
|
257
|
+
"prompt_tokens": 0,
|
|
258
|
+
"total_tokens": 0
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
return model_usage
|
|
262
|
+
|
|
263
|
+
def get_message_timing_stats(self) -> Dict[str, Any]:
|
|
264
|
+
"""Calculate timing statistics across all messages
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Dictionary containing:
|
|
268
|
+
- total_latency: Total processing time across all messages (in milliseconds)
|
|
269
|
+
- average_latency: Average processing time per message (in milliseconds)
|
|
270
|
+
- message_count: Total number of messages with timing data
|
|
271
|
+
"""
|
|
272
|
+
total_latency = 0
|
|
273
|
+
message_count = 0
|
|
274
|
+
|
|
275
|
+
for message in self.messages:
|
|
276
|
+
if message.metrics and message.metrics.get("timing", {}).get("latency"):
|
|
277
|
+
total_latency += message.metrics["timing"]["latency"]
|
|
278
|
+
message_count += 1
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
"total_latency": total_latency,
|
|
282
|
+
"average_latency": total_latency / message_count if message_count > 0 else 0,
|
|
283
|
+
"message_count": message_count
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def get_message_counts(self) -> Dict[str, int]:
|
|
287
|
+
"""Get count of messages by role
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Dictionary with counts for each role (system, user, assistant, tool)
|
|
291
|
+
"""
|
|
292
|
+
counts = {
|
|
293
|
+
"system": 0,
|
|
294
|
+
"user": 0,
|
|
295
|
+
"assistant": 0,
|
|
296
|
+
"tool": 0
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for message in self.messages:
|
|
300
|
+
counts[message.role] += 1
|
|
301
|
+
|
|
302
|
+
return counts
|
|
303
|
+
|
|
304
|
+
def get_tool_usage(self) -> Dict[str, Any]:
|
|
305
|
+
"""Get count of tool function calls in the thread
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Dictionary containing:
|
|
309
|
+
- tools: Dictionary of tool names and their call counts
|
|
310
|
+
- total_calls: Total number of tool calls made
|
|
311
|
+
"""
|
|
312
|
+
tool_counts = {} # {"tool_name": count}
|
|
313
|
+
|
|
314
|
+
for message in self.messages:
|
|
315
|
+
if message.role == "assistant" and message.tool_calls:
|
|
316
|
+
for call in message.tool_calls:
|
|
317
|
+
if isinstance(call, dict):
|
|
318
|
+
tool_name = call.get("function", {}).get("name")
|
|
319
|
+
else:
|
|
320
|
+
# Handle OpenAI tool call objects
|
|
321
|
+
tool_name = getattr(call.function, "name", None)
|
|
322
|
+
|
|
323
|
+
if tool_name:
|
|
324
|
+
tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
"tools": tool_counts,
|
|
328
|
+
"total_calls": sum(tool_counts.values())
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
def get_system_message(self) -> Optional[Message]:
|
|
332
|
+
"""Get the system message from the thread if it exists"""
|
|
333
|
+
for message in self.messages:
|
|
334
|
+
if message.role == "system":
|
|
335
|
+
return message
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def get_messages_in_sequence(self) -> List[Message]:
|
|
339
|
+
"""Get messages sorted by sequence number"""
|
|
340
|
+
return sorted(self.messages, key=lambda m: m.sequence if m.sequence is not None else float('inf'))
|
|
341
|
+
|
|
342
|
+
def get_messages_by_turn(self, turn: int) -> List[Message]:
|
|
343
|
+
"""Get all messages in a specific turn
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
turn: The turn number to retrieve messages for
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
List of messages in the specified turn, sorted by sequence
|
|
350
|
+
"""
|
|
351
|
+
turn_messages = [m for m in self.messages if m.turn == turn]
|
|
352
|
+
return sorted(turn_messages, key=lambda m: m.sequence if m.sequence is not None else float('inf'))
|
|
353
|
+
|
|
354
|
+
def get_current_turn(self) -> int:
|
|
355
|
+
"""Get the current turn number (highest turn number in the thread)
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Current turn number, or 0 if no messages
|
|
359
|
+
"""
|
|
360
|
+
if not self.messages:
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
# Exclude system messages when determining current turn
|
|
364
|
+
non_system_messages = [m for m in self.messages if m.role != "system"]
|
|
365
|
+
if not non_system_messages:
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
return max(m.turn for m in non_system_messages if m.turn is not None)
|
|
369
|
+
|
|
370
|
+
def get_turns_summary(self) -> Dict[int, Dict[str, Any]]:
|
|
371
|
+
"""Get a summary of all turns in the thread
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Dictionary mapping turn numbers to turn information
|
|
375
|
+
"""
|
|
376
|
+
turns = {}
|
|
377
|
+
|
|
378
|
+
for message in self.messages:
|
|
379
|
+
if message.turn is None or message.role == "system":
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
turn = message.turn
|
|
383
|
+
if turn not in turns:
|
|
384
|
+
turns[turn] = {
|
|
385
|
+
"turn": turn,
|
|
386
|
+
"message_count": 0,
|
|
387
|
+
"roles": {},
|
|
388
|
+
"first_message_time": message.timestamp,
|
|
389
|
+
"last_message_time": message.timestamp
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
turns[turn]["message_count"] += 1
|
|
393
|
+
turns[turn]["roles"][message.role] = turns[turn]["roles"].get(message.role, 0) + 1
|
|
394
|
+
|
|
395
|
+
# Update timestamps
|
|
396
|
+
if message.timestamp < turns[turn]["first_message_time"]:
|
|
397
|
+
turns[turn]["first_message_time"] = message.timestamp
|
|
398
|
+
if message.timestamp > turns[turn]["last_message_time"]:
|
|
399
|
+
turns[turn]["last_message_time"] = message.timestamp
|
|
400
|
+
|
|
401
|
+
return turns
|
|
402
|
+
|
|
403
|
+
def get_message_by_id(self, message_id: str) -> Optional[Message]:
|
|
404
|
+
"""Return the message with the specified ID, or None if no message exists with that ID"""
|
|
405
|
+
for message in self.messages:
|
|
406
|
+
if message.id == message_id:
|
|
407
|
+
return message
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
def add_reaction(self, message_id: str, emoji: str, user_id: str) -> bool:
|
|
411
|
+
"""Add a reaction to a message in the thread
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
message_id: ID of the message to react to
|
|
415
|
+
emoji: Emoji shortcode (e.g., ":thumbsup:")
|
|
416
|
+
user_id: ID of the user adding the reaction
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
True if reaction was added, False if it wasn't (message not found or already reacted)
|
|
420
|
+
"""
|
|
421
|
+
message = self.get_message_by_id(message_id)
|
|
422
|
+
if not message:
|
|
423
|
+
logging.getLogger(__name__).warning(f"Thread.add_reaction (thread_id={self.id}): Message with ID '{message_id}' not found.")
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
result = message.add_reaction(emoji, user_id)
|
|
427
|
+
if result:
|
|
428
|
+
self.updated_at = datetime.now(timezone.utc) # Ensure thread update time is changed
|
|
429
|
+
logging.getLogger(__name__).info(f"Thread.add_reaction (thread_id={self.id}): Message '{message_id}' reactions updated. Thread updated_at: {self.updated_at}")
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
def remove_reaction(self, message_id: str, emoji: str, user_id: str) -> bool:
|
|
433
|
+
"""Remove a reaction from a message in the thread
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
message_id: ID of the message to remove reaction from
|
|
437
|
+
emoji: Emoji shortcode (e.g., ":thumbsup:")
|
|
438
|
+
user_id: ID of the user removing the reaction
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
True if reaction was removed, False if it wasn't (message or reaction not found)
|
|
442
|
+
"""
|
|
443
|
+
message = self.get_message_by_id(message_id)
|
|
444
|
+
if not message:
|
|
445
|
+
logging.getLogger(__name__).warning(f"Thread.remove_reaction (thread_id={self.id}): Message with ID '{message_id}' not found.")
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
result = message.remove_reaction(emoji, user_id)
|
|
449
|
+
if result:
|
|
450
|
+
self.updated_at = datetime.now(timezone.utc) # Ensure thread update time is changed
|
|
451
|
+
logging.getLogger(__name__).info(f"Thread.remove_reaction (thread_id={self.id}): Message '{message_id}' reactions updated. Thread updated_at: {self.updated_at}")
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
def get_reactions(self, message_id: str) -> Dict[str, List[str]]:
|
|
455
|
+
"""Get all reactions for a message in the thread
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
message_id: ID of the message to get reactions for
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Dictionary mapping emoji to list of user IDs, or empty dict if message not found
|
|
462
|
+
"""
|
|
463
|
+
message = self.get_message_by_id(message_id)
|
|
464
|
+
if not message:
|
|
465
|
+
return {}
|
|
466
|
+
|
|
467
|
+
return message.get_reactions()
|