feishu_drive_tool.py
1 """Feishu Drive Tools -- document comment operations via Feishu/Lark API. 2 3 Provides tools for listing, replying to, and adding document comments. 4 Uses the same lazy-import + BaseRequest pattern as feishu_comment.py. 5 The lark client is injected per-thread by the comment event handler. 6 """ 7 8 import json 9 import logging 10 import threading 11 12 from tools.registry import registry, tool_error, tool_result 13 14 logger = logging.getLogger(__name__) 15 16 # Thread-local storage for the lark client injected by feishu_comment handler. 17 _local = threading.local() 18 19 20 def set_client(client): 21 """Store a lark client for the current thread (called by feishu_comment).""" 22 _local.client = client 23 24 25 def get_client(): 26 """Return the lark client for the current thread, or None.""" 27 return getattr(_local, "client", None) 28 29 30 def _check_feishu(): 31 try: 32 import lark_oapi # noqa: F401 33 return True 34 except ImportError: 35 return False 36 37 38 def _do_request(client, method, uri, paths=None, queries=None, body=None): 39 """Build and execute a BaseRequest, return (code, msg, data_dict).""" 40 from lark_oapi import AccessTokenType 41 from lark_oapi.core.enum import HttpMethod 42 from lark_oapi.core.model.base_request import BaseRequest 43 44 http_method = HttpMethod.GET if method == "GET" else HttpMethod.POST 45 46 builder = ( 47 BaseRequest.builder() 48 .http_method(http_method) 49 .uri(uri) 50 .token_types({AccessTokenType.TENANT}) 51 ) 52 if paths: 53 builder = builder.paths(paths) 54 if queries: 55 builder = builder.queries(queries) 56 if body is not None: 57 builder = builder.body(body) 58 59 request = builder.build() 60 61 # Tool handlers run synchronously in a worker thread (no running event 62 # loop), so call the blocking lark client directly. 63 response = client.request(request) 64 65 code = getattr(response, "code", None) 66 msg = getattr(response, "msg", "") 67 68 # Parse response data 69 data = {} 70 raw = getattr(response, "raw", None) 71 if raw and hasattr(raw, "content"): 72 try: 73 body_json = json.loads(raw.content) 74 data = body_json.get("data", {}) 75 except (json.JSONDecodeError, AttributeError): 76 pass 77 if not data: 78 resp_data = getattr(response, "data", None) 79 if isinstance(resp_data, dict): 80 data = resp_data 81 elif resp_data and hasattr(resp_data, "__dict__"): 82 data = vars(resp_data) 83 84 return code, msg, data 85 86 87 # --------------------------------------------------------------------------- 88 # feishu_drive_list_comments 89 # --------------------------------------------------------------------------- 90 91 _LIST_COMMENTS_URI = "/open-apis/drive/v1/files/:file_token/comments" 92 93 FEISHU_DRIVE_LIST_COMMENTS_SCHEMA = { 94 "name": "feishu_drive_list_comments", 95 "description": ( 96 "List comments on a Feishu document. " 97 "Use is_whole=true to list whole-document comments only." 98 ), 99 "parameters": { 100 "type": "object", 101 "properties": { 102 "file_token": { 103 "type": "string", 104 "description": "The document file token.", 105 }, 106 "file_type": { 107 "type": "string", 108 "description": "File type (default: docx).", 109 "default": "docx", 110 }, 111 "is_whole": { 112 "type": "boolean", 113 "description": "If true, only return whole-document comments.", 114 "default": False, 115 }, 116 "page_size": { 117 "type": "integer", 118 "description": "Number of comments per page (max 100).", 119 "default": 100, 120 }, 121 "page_token": { 122 "type": "string", 123 "description": "Pagination token for next page.", 124 }, 125 }, 126 "required": ["file_token"], 127 }, 128 } 129 130 131 def _handle_list_comments(args: dict, **kwargs) -> str: 132 client = get_client() 133 if client is None: 134 return tool_error("Feishu client not available") 135 136 file_token = args.get("file_token", "").strip() 137 if not file_token: 138 return tool_error("file_token is required") 139 140 file_type = args.get("file_type", "docx") or "docx" 141 is_whole = args.get("is_whole", False) 142 page_size = args.get("page_size", 100) 143 page_token = args.get("page_token", "") 144 145 queries = [ 146 ("file_type", file_type), 147 ("user_id_type", "open_id"), 148 ("page_size", str(page_size)), 149 ] 150 if is_whole: 151 queries.append(("is_whole", "true")) 152 if page_token: 153 queries.append(("page_token", page_token)) 154 155 code, msg, data = _do_request( 156 client, "GET", _LIST_COMMENTS_URI, 157 paths={"file_token": file_token}, 158 queries=queries, 159 ) 160 if code != 0: 161 return tool_error(f"List comments failed: code={code} msg={msg}") 162 163 return tool_result(data) 164 165 166 # --------------------------------------------------------------------------- 167 # feishu_drive_list_comment_replies 168 # --------------------------------------------------------------------------- 169 170 _LIST_REPLIES_URI = "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies" 171 172 FEISHU_DRIVE_LIST_REPLIES_SCHEMA = { 173 "name": "feishu_drive_list_comment_replies", 174 "description": "List all replies in a comment thread on a Feishu document.", 175 "parameters": { 176 "type": "object", 177 "properties": { 178 "file_token": { 179 "type": "string", 180 "description": "The document file token.", 181 }, 182 "comment_id": { 183 "type": "string", 184 "description": "The comment ID to list replies for.", 185 }, 186 "file_type": { 187 "type": "string", 188 "description": "File type (default: docx).", 189 "default": "docx", 190 }, 191 "page_size": { 192 "type": "integer", 193 "description": "Number of replies per page (max 100).", 194 "default": 100, 195 }, 196 "page_token": { 197 "type": "string", 198 "description": "Pagination token for next page.", 199 }, 200 }, 201 "required": ["file_token", "comment_id"], 202 }, 203 } 204 205 206 def _handle_list_replies(args: dict, **kwargs) -> str: 207 client = get_client() 208 if client is None: 209 return tool_error("Feishu client not available") 210 211 file_token = args.get("file_token", "").strip() 212 comment_id = args.get("comment_id", "").strip() 213 if not file_token or not comment_id: 214 return tool_error("file_token and comment_id are required") 215 216 file_type = args.get("file_type", "docx") or "docx" 217 page_size = args.get("page_size", 100) 218 page_token = args.get("page_token", "") 219 220 queries = [ 221 ("file_type", file_type), 222 ("user_id_type", "open_id"), 223 ("page_size", str(page_size)), 224 ] 225 if page_token: 226 queries.append(("page_token", page_token)) 227 228 code, msg, data = _do_request( 229 client, "GET", _LIST_REPLIES_URI, 230 paths={"file_token": file_token, "comment_id": comment_id}, 231 queries=queries, 232 ) 233 if code != 0: 234 return tool_error(f"List replies failed: code={code} msg={msg}") 235 236 return tool_result(data) 237 238 239 # --------------------------------------------------------------------------- 240 # feishu_drive_reply_comment 241 # --------------------------------------------------------------------------- 242 243 _REPLY_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies" 244 245 FEISHU_DRIVE_REPLY_SCHEMA = { 246 "name": "feishu_drive_reply_comment", 247 "description": ( 248 "Reply to a local comment thread on a Feishu document. " 249 "Use this for local (quoted-text) comments. " 250 "For whole-document comments, use feishu_drive_add_comment instead." 251 ), 252 "parameters": { 253 "type": "object", 254 "properties": { 255 "file_token": { 256 "type": "string", 257 "description": "The document file token.", 258 }, 259 "comment_id": { 260 "type": "string", 261 "description": "The comment ID to reply to.", 262 }, 263 "content": { 264 "type": "string", 265 "description": "The reply text content (plain text only, no markdown).", 266 }, 267 "file_type": { 268 "type": "string", 269 "description": "File type (default: docx).", 270 "default": "docx", 271 }, 272 }, 273 "required": ["file_token", "comment_id", "content"], 274 }, 275 } 276 277 278 def _handle_reply_comment(args: dict, **kwargs) -> str: 279 client = get_client() 280 if client is None: 281 return tool_error("Feishu client not available") 282 283 file_token = args.get("file_token", "").strip() 284 comment_id = args.get("comment_id", "").strip() 285 content = args.get("content", "").strip() 286 if not file_token or not comment_id or not content: 287 return tool_error("file_token, comment_id, and content are required") 288 289 file_type = args.get("file_type", "docx") or "docx" 290 291 body = { 292 "content": { 293 "elements": [ 294 { 295 "type": "text_run", 296 "text_run": {"text": content}, 297 } 298 ] 299 } 300 } 301 302 code, msg, data = _do_request( 303 client, "POST", _REPLY_COMMENT_URI, 304 paths={"file_token": file_token, "comment_id": comment_id}, 305 queries=[("file_type", file_type)], 306 body=body, 307 ) 308 if code != 0: 309 return tool_error(f"Reply comment failed: code={code} msg={msg}") 310 311 return tool_result(success=True, data=data) 312 313 314 # --------------------------------------------------------------------------- 315 # feishu_drive_add_comment 316 # --------------------------------------------------------------------------- 317 318 _ADD_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/new_comments" 319 320 FEISHU_DRIVE_ADD_COMMENT_SCHEMA = { 321 "name": "feishu_drive_add_comment", 322 "description": ( 323 "Add a new whole-document comment on a Feishu document. " 324 "Use this for whole-document comments or as a fallback when " 325 "reply_comment fails with code 1069302." 326 ), 327 "parameters": { 328 "type": "object", 329 "properties": { 330 "file_token": { 331 "type": "string", 332 "description": "The document file token.", 333 }, 334 "content": { 335 "type": "string", 336 "description": "The comment text content (plain text only, no markdown).", 337 }, 338 "file_type": { 339 "type": "string", 340 "description": "File type (default: docx).", 341 "default": "docx", 342 }, 343 }, 344 "required": ["file_token", "content"], 345 }, 346 } 347 348 349 def _handle_add_comment(args: dict, **kwargs) -> str: 350 client = get_client() 351 if client is None: 352 return tool_error("Feishu client not available") 353 354 file_token = args.get("file_token", "").strip() 355 content = args.get("content", "").strip() 356 if not file_token or not content: 357 return tool_error("file_token and content are required") 358 359 file_type = args.get("file_type", "docx") or "docx" 360 361 body = { 362 "file_type": file_type, 363 "reply_elements": [ 364 {"type": "text", "text": content}, 365 ], 366 } 367 368 code, msg, data = _do_request( 369 client, "POST", _ADD_COMMENT_URI, 370 paths={"file_token": file_token}, 371 body=body, 372 ) 373 if code != 0: 374 return tool_error(f"Add comment failed: code={code} msg={msg}") 375 376 return tool_result(success=True, data=data) 377 378 379 # --------------------------------------------------------------------------- 380 # Registration 381 # --------------------------------------------------------------------------- 382 383 registry.register( 384 name="feishu_drive_list_comments", 385 toolset="feishu_drive", 386 schema=FEISHU_DRIVE_LIST_COMMENTS_SCHEMA, 387 handler=_handle_list_comments, 388 check_fn=_check_feishu, 389 requires_env=[], 390 is_async=False, 391 description="List document comments", 392 emoji="\U0001f4ac", 393 ) 394 395 registry.register( 396 name="feishu_drive_list_comment_replies", 397 toolset="feishu_drive", 398 schema=FEISHU_DRIVE_LIST_REPLIES_SCHEMA, 399 handler=_handle_list_replies, 400 check_fn=_check_feishu, 401 requires_env=[], 402 is_async=False, 403 description="List comment replies", 404 emoji="\U0001f4ac", 405 ) 406 407 registry.register( 408 name="feishu_drive_reply_comment", 409 toolset="feishu_drive", 410 schema=FEISHU_DRIVE_REPLY_SCHEMA, 411 handler=_handle_reply_comment, 412 check_fn=_check_feishu, 413 requires_env=[], 414 is_async=False, 415 description="Reply to a document comment", 416 emoji="\u2709\ufe0f", 417 ) 418 419 registry.register( 420 name="feishu_drive_add_comment", 421 toolset="feishu_drive", 422 schema=FEISHU_DRIVE_ADD_COMMENT_SCHEMA, 423 handler=_handle_add_comment, 424 check_fn=_check_feishu, 425 requires_env=[], 426 is_async=False, 427 description="Add a whole-document comment", 428 emoji="\u2709\ufe0f", 429 )