Coverage for slack_bot / obsidian / dispatcher.py: 0%
137 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 17:44 +0800
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 17:44 +0800
1import os
2from typing import Optional, List, Dict, Any
3from slack_sdk import WebClient
5from health.utils.logging_config import setup_logger
6from slack_bot.obsidian.indexer import ObsidianIndexer
7from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
8from slack_bot.context.storage import ContextStorage as BaseStorage
10logger = setup_logger(__name__)
12class ObsidianContextStorage(BaseStorage):
13 """Specific storage for Obsidian bot to avoid mixing with Health bot."""
14 def __init__(self, channel_id: str):
15 super().__init__(channel_id)
16 from health import config
17 self.context_dir = config.DATA_DIR / "obsidian_context"
18 self.context_dir.mkdir(parents=True, exist_ok=True)
19 self.file_path = self.context_dir / f"{channel_id}.json"
20 self.state_file = self.context_dir / f"{channel_id}_state.json"
22 def save_state(self, state: Dict[str, Any]):
23 import json
24 with open(self.state_file, "w") as f:
25 json.dump(state, f)
27 def load_state(self) -> Dict[str, Any]:
28 import json
29 if not self.state_file.exists():
30 return {"mode": "none"}
31 try:
32 with open(self.state_file, "r") as f:
33 return json.load(f)
34 except:
35 return {"mode": "none"}
37class ObsidianDispatcher:
38 """Routes Obsidian Slack Bot messages."""
40 MODE_WRITE = "write"
41 MODE_REPLY = "reply"
42 MODE_DECIDE = "decide"
43 MODE_SEARCH = "search"
44 MODE_DEAI = "deai"
45 MODE_ZHIHU = "zhihu"
47 def __init__(self, vault_path: str):
48 self.indexer = ObsidianIndexer(vault_path)
49 self.indexer.scan_vault()
51 self.generators = {
52 self.MODE_WRITE: WritingAssistant(self.indexer),
53 self.MODE_REPLY: ReplyGenerator(self.indexer),
54 self.MODE_DECIDE: DecisionSupport(self.indexer),
55 self.MODE_SEARCH: SearchAnalyzer(self.indexer),
56 self.MODE_DEAI: DeAIReviser(self.indexer),
57 self.MODE_ZHIHU: ZhihuGenerator(self.indexer)
58 }
60 token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
61 self.client = WebClient(token=token)
62 logger.info("ObsidianDispatcher initialized")
64 def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
65 storage = ObsidianContextStorage(channel_id)
66 state = storage.load_state()
67 current_mode = state.get("mode", "none")
69 cmd = text.strip().lower()
71 # 1. Handle Commands
72 if cmd.startswith("mode "):
73 new_mode = cmd.split(" ", 1)[1].strip()
74 if new_mode in self.generators:
75 storage.clear()
76 storage.save_state({"mode": new_mode})
77 self._reply(channel_id, f"✅ Switched to mode: *{new_mode}* (History cleared)", response_ts)
78 return
79 else:
80 self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(self.generators.keys())}", response_ts)
81 return
83 if cmd == "clear":
84 storage.clear()
85 self._reply(channel_id, "🧹 Context cleared.", response_ts)
86 return
88 if cmd == "reload":
89 self.indexer.scan_vault()
90 self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
91 return
93 # 2. Handle Content based on mode
94 if current_mode == "none":
95 self._reply(channel_id, "⚠️ No mode selected. Use `mode write/reply/decide/search/deai/zhihu` to start.", response_ts)
96 return
98 # Get history
99 history = storage.get_context()
100 generator = self.generators[current_mode]
102 try:
103 logger.info(f"Generating Obsidian response in mode: {current_mode}")
104 # The chat method handles first turn logic and returns updated history
105 # But wait, our generators' chat method appends to history internally.
106 # In our Slack setup, we want to append the result to ContextStorage.
108 # Note: generator.chat(text, history) returns (response, new_history_list)
109 # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
110 response_text, updated_history = generator.chat(text, history)
112 # Since generator.chat already did the LLM call and returned history,
113 # we just need to save the ONLY NEW messages to our storage.
114 # However, during FIRST TURN, text is replaced by rich_prompt.
116 # For simplicity: completely overwrite storage with updated_history
117 # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
118 # Better: Modify ObsidianContextStorage to support set_context.
120 # Actually, let's just use the updated_history and manually add to storage
121 # Warning: first turn 'user' message in storage will be the RICH PROMPT
122 # This is actually better for context continuity in Slack too.
124 storage.clear()
125 for msg in updated_history:
126 storage.add_message(msg["role"], msg["content"])
128 self._reply(channel_id, response_text, response_ts)
130 except Exception as e:
131 logger.error(f"Obsidian generation failed: {e}", exc_info=True)
132 self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
134 def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
135 from slack_bot.utils.mrkdwn import SlackFormatter
137 # Slack API limits (official documentation):
138 # - chat.postMessage: 40,000 chars
139 # - chat.update: 4,000 chars
140 # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
141 TRUNCATE_WARNING = "\n\n⚠️ (Response truncated, full text uploaded as file)"
142 FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝️"
144 # Calculate safe pre-formatting limit with aggressive overhead buffer
145 if ts:
146 # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
147 max_raw_length = 1500
148 else:
149 # For new messages: 40000 limit - 40% overhead = ~24000 chars
150 max_raw_length = 24000
152 original_length = len(text)
153 should_upload_file = original_length > max_raw_length
155 if should_upload_file:
156 # Upload full text as file first
157 self._upload_as_file(channel_id, text, ts)
159 # Then send a truncated preview
160 truncate_at = text.rfind('\n\n', 0, 800)
161 if truncate_at == -1 or truncate_at < 400:
162 truncate_at = text.rfind('\n', 0, 800)
163 if truncate_at == -1 or truncate_at < 400:
164 truncate_at = 800
166 preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
167 formatted_text = SlackFormatter.convert(preview_text)
168 logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
169 else:
170 # Text is short enough, just format and send
171 formatted_text = SlackFormatter.convert(text)
173 # Final safety check after formatting
174 final_limit = 3900 if ts else 39900
175 if len(formatted_text) > final_limit:
176 logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
177 self._upload_as_file(channel_id, text, ts)
178 # Send simple notification
179 formatted_text = FILE_UPLOAD_HINT
181 # Send the message
182 try:
183 if ts:
184 self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
185 else:
186 self.client.chat_postMessage(channel=channel_id, text=formatted_text)
187 except Exception as e:
188 if "msg_too_long" in str(e):
189 logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
190 # Ultimate fallback: send error message only
191 try:
192 error_msg = "❌ Response formatting error. Please check the uploaded file above."
193 if ts:
194 self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
195 else:
196 self.client.chat_postMessage(channel=channel_id, text=error_msg)
197 except:
198 logger.error("Failed to send even error message", exc_info=True)
199 else:
200 raise
202 def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
203 """Fallback: upload long responses as text files."""
204 try:
205 import tempfile
206 from datetime import datetime
208 filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
210 with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
211 tmp.write(content)
212 tmp_path = tmp.name
214 self.client.files_upload_v2(
215 channel=channel_id,
216 file=tmp_path,
217 filename=filename,
218 initial_comment="📄 Response too long for Slack message, uploaded as file:",
219 thread_ts=ts
220 )
222 os.unlink(tmp_path)
223 logger.info(f"Uploaded long response as file: {filename}")
224 except Exception as e:
225 logger.error(f"Failed to upload file: {e}", exc_info=True)
226 # Ultimate fallback: send error message
227 error_msg = "❌ Response too long to display. Please try a more specific query."
228 if ts:
229 self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
230 else:
231 self.client.chat_postMessage(channel=channel_id, text=error_msg)