/ tools / feishu_drive_tool.py
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  )