@m1heng-clawd/feishu 0.1.15 → 0.1.17

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.
Files changed (39) hide show
  1. package/README.md +28 -0
  2. package/index.ts +4 -0
  3. package/package.json +2 -1
  4. package/skills/feishu-message/SKILL.md +111 -0
  5. package/skills/feishu-reaction/SKILL.md +120 -0
  6. package/src/bot.ts +4 -0
  7. package/src/channel.ts +2 -0
  8. package/src/chat-tools/actions.ts +102 -33
  9. package/src/chat-tools/schemas.ts +57 -62
  10. package/src/config-schema.ts +2 -0
  11. package/src/doc-tools/actions.ts +63 -14
  12. package/src/doc-tools/schemas.ts +41 -77
  13. package/src/doc-write-service.ts +19 -0
  14. package/src/drive-tools/actions.ts +18 -6
  15. package/src/drive-tools/register.ts +1 -1
  16. package/src/drive-tools/schemas.ts +45 -58
  17. package/src/media-duration.ts +43 -8
  18. package/src/media.ts +31 -53
  19. package/src/message-tools/actions.ts +154 -0
  20. package/src/message-tools/index.ts +2 -0
  21. package/src/message-tools/register.ts +58 -0
  22. package/src/message-tools/schemas.ts +56 -0
  23. package/src/outbound.ts +2 -2
  24. package/src/perm-tools/actions.ts +27 -3
  25. package/src/perm-tools/schemas.ts +39 -45
  26. package/src/reaction-tools/actions.ts +127 -0
  27. package/src/reaction-tools/index.ts +2 -0
  28. package/src/reaction-tools/register.ts +51 -0
  29. package/src/reaction-tools/schemas.ts +34 -0
  30. package/src/reply-dispatcher.ts +23 -33
  31. package/src/streaming-card.ts +34 -11
  32. package/src/task-tools/actions.ts +145 -10
  33. package/src/task-tools/register.ts +56 -1
  34. package/src/task-tools/schemas.ts +23 -18
  35. package/src/tools-common/tool-context.ts +2 -0
  36. package/src/tools-config.ts +2 -0
  37. package/src/types.ts +4 -0
  38. package/src/wiki-tools/actions.ts +24 -6
  39. package/src/wiki-tools/schemas.ts +32 -51
@@ -0,0 +1,127 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { runFeishuApiCall, type FeishuApiResponse } from "../tools-common/feishu-api.js";
3
+ import type { FeishuReactionParams } from "./schemas.js";
4
+
5
+ interface ReactionCreateResponse extends FeishuApiResponse {
6
+ data?: { reaction_id?: string };
7
+ }
8
+
9
+ interface ReactionListResponse extends FeishuApiResponse {
10
+ data?: {
11
+ items?: Array<{
12
+ reaction_id?: string;
13
+ reaction_type?: { emoji_type?: string };
14
+ operator_type?: string;
15
+ operator_id?: { open_id?: string; user_id?: string; union_id?: string };
16
+ }>;
17
+ has_more?: boolean;
18
+ page_token?: string;
19
+ };
20
+ }
21
+
22
+ export async function runReactionAction(
23
+ client: Lark.Client,
24
+ params: FeishuReactionParams,
25
+ ): Promise<unknown> {
26
+ switch (params.action) {
27
+ case "add":
28
+ return addReaction(client, params);
29
+ case "remove":
30
+ return removeReaction(client, params);
31
+ case "list":
32
+ return listReactions(client, params);
33
+ }
34
+ }
35
+
36
+ async function addReaction(client: Lark.Client, params: FeishuReactionParams) {
37
+ if (!params.emoji_type) {
38
+ throw new Error("emoji_type is required for action=add");
39
+ }
40
+
41
+ const response = await runFeishuApiCall<ReactionCreateResponse>(
42
+ "Feishu add reaction",
43
+ () =>
44
+ client.im.messageReaction.create({
45
+ path: { message_id: params.message_id },
46
+ data: { reaction_type: { emoji_type: params.emoji_type! } },
47
+ }) as Promise<ReactionCreateResponse>,
48
+ );
49
+
50
+ return {
51
+ ok: true,
52
+ action: "add",
53
+ message_id: params.message_id,
54
+ emoji_type: params.emoji_type,
55
+ reaction_id: response.data?.reaction_id ?? null,
56
+ };
57
+ }
58
+
59
+ async function removeReaction(client: Lark.Client, params: FeishuReactionParams) {
60
+ if (!params.reaction_id) {
61
+ throw new Error("reaction_id is required for action=remove");
62
+ }
63
+
64
+ await runFeishuApiCall<FeishuApiResponse>(
65
+ "Feishu remove reaction",
66
+ () =>
67
+ client.im.messageReaction.delete({
68
+ path: {
69
+ message_id: params.message_id,
70
+ reaction_id: params.reaction_id!,
71
+ },
72
+ }) as Promise<FeishuApiResponse>,
73
+ );
74
+
75
+ return {
76
+ ok: true,
77
+ action: "remove",
78
+ message_id: params.message_id,
79
+ reaction_id: params.reaction_id,
80
+ };
81
+ }
82
+
83
+ async function listReactions(client: Lark.Client, params: FeishuReactionParams) {
84
+ const allItems: Array<{
85
+ reaction_id: string;
86
+ emoji_type: string;
87
+ operator_type: string;
88
+ operator_id: string;
89
+ }> = [];
90
+
91
+ let pageToken: string | undefined;
92
+ do {
93
+ const response = await runFeishuApiCall<ReactionListResponse>(
94
+ "Feishu list reactions",
95
+ () =>
96
+ client.im.messageReaction.list({
97
+ path: { message_id: params.message_id },
98
+ params: {
99
+ ...(params.emoji_type ? { reaction_type: params.emoji_type } : {}),
100
+ ...(pageToken ? { page_token: pageToken } : {}),
101
+ page_size: 50,
102
+ },
103
+ }) as Promise<ReactionListResponse>,
104
+ );
105
+
106
+ for (const item of response.data?.items ?? []) {
107
+ allItems.push({
108
+ reaction_id: item.reaction_id ?? "",
109
+ emoji_type: item.reaction_type?.emoji_type ?? "",
110
+ operator_type: item.operator_type ?? "user",
111
+ operator_id:
112
+ item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
113
+ });
114
+ }
115
+
116
+ pageToken = response.data?.has_more ? (response.data.page_token ?? undefined) : undefined;
117
+ } while (pageToken);
118
+
119
+ return {
120
+ ok: true,
121
+ action: "list",
122
+ message_id: params.message_id,
123
+ emoji_type_filter: params.emoji_type ?? null,
124
+ total: allItems.length,
125
+ reactions: allItems,
126
+ };
127
+ }
@@ -0,0 +1,2 @@
1
+ export { registerFeishuReactionTools } from "./register.js";
2
+ export { FeishuReactionSchema, type FeishuReactionParams } from "./schemas.js";
@@ -0,0 +1,51 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
3
+ import { errorResult, json } from "../tools-common/feishu-api.js";
4
+ import { runReactionAction } from "./actions.js";
5
+ import { FeishuReactionSchema, type FeishuReactionParams } from "./schemas.js";
6
+
7
+ export function registerFeishuReactionTools(api: OpenClawPluginApi) {
8
+ if (!api.config) {
9
+ api.logger.debug?.("feishu_reaction: No config available, skipping");
10
+ return;
11
+ }
12
+
13
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
14
+ api.logger.debug?.("feishu_reaction: No Feishu accounts configured, skipping");
15
+ return;
16
+ }
17
+
18
+ if (!hasFeishuToolEnabledForAnyAccount(api.config, "reaction")) {
19
+ api.logger.debug?.("feishu_reaction: reaction tool disabled in config");
20
+ return;
21
+ }
22
+
23
+ api.registerTool(
24
+ {
25
+ name: "feishu_reaction",
26
+ label: "Feishu Reaction",
27
+ description:
28
+ "Add, remove, or list emoji reactions on Feishu messages. " +
29
+ "Supported actions: add (add an emoji reaction), remove (remove a reaction by ID), list (list all reactions). " +
30
+ "Common emoji types: THUMBSUP, THUMBSDOWN, HEART, SMILE, GRINNING, FIRE, CLAP, OK, CHECK, CROSS, PARTY, PRAY. " +
31
+ "To react to a previous message, first use feishu_message list to find the target message_id.",
32
+ parameters: FeishuReactionSchema,
33
+ async execute(_toolCallId, params) {
34
+ const p = params as FeishuReactionParams;
35
+ try {
36
+ return await withFeishuToolClient({
37
+ api,
38
+ toolName: "feishu_reaction",
39
+ requiredTool: "reaction",
40
+ run: async ({ client }) => json(await runReactionAction(client, p)),
41
+ });
42
+ } catch (err) {
43
+ return errorResult(err);
44
+ }
45
+ },
46
+ },
47
+ { name: "feishu_reaction" },
48
+ );
49
+
50
+ api.logger.debug?.("feishu_reaction: Registered feishu_reaction tool");
51
+ }
@@ -0,0 +1,34 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
3
+ function stringEnum<T extends readonly string[]>(
4
+ values: T,
5
+ options: { description?: string; default?: T[number] } = {},
6
+ ) {
7
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
8
+ }
9
+
10
+ const ACTION_VALUES = ["add", "remove", "list"] as const;
11
+
12
+ export const FeishuReactionSchema = Type.Object({
13
+ action: stringEnum(ACTION_VALUES, {
14
+ description:
15
+ "Action to perform: add (add emoji reaction to a message), remove (remove a reaction by reaction_id), list (list all reactions on a message).",
16
+ }),
17
+ message_id: Type.String({
18
+ description: "Feishu message ID (e.g. om_xxx). Use feishu_message list to find message_ids from chat history.",
19
+ }),
20
+ emoji_type: Type.Optional(
21
+ Type.String({
22
+ description:
23
+ "Emoji type to add or filter by (e.g. THUMBSUP, HEART, SMILE, GRINNING, FIRE, CLAP, OK, CHECK, CROSS). " +
24
+ "Required for action=add. Optional filter for action=list.",
25
+ }),
26
+ ),
27
+ reaction_id: Type.Optional(
28
+ Type.String({
29
+ description: "Reaction ID to remove. Required for action=remove. Obtained from add or list results.",
30
+ }),
31
+ ),
32
+ });
33
+
34
+ export type FeishuReactionParams = Static<typeof FeishuReactionSchema>;
@@ -12,7 +12,7 @@ import { buildMentionedCardContent, type MentionTarget } from "./mention.js";
12
12
  import { normalizeFeishuMarkdownLinks } from "./text/markdown-links.js";
13
13
  import { getFeishuRuntime } from "./runtime.js";
14
14
  import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
15
- import { FeishuStreamingSession } from "./streaming-card.js";
15
+ import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
16
16
  import { resolveReceiveIdType } from "./targets.js";
17
17
  import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
18
18
 
@@ -119,39 +119,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
119
119
  let streaming: FeishuStreamingSession | null = null;
120
120
  let streamText = "";
121
121
  let lastPartial = "";
122
+ let streamingCompleted = false;
122
123
  let partialUpdateQueue: Promise<void> = Promise.resolve();
123
124
  let streamingStartPromise: Promise<void> | null = null;
124
125
 
125
- const mergeStreamingText = (nextText: string) => {
126
- if (!streamText) {
127
- streamText = nextText;
128
- return;
129
- }
130
- if (nextText.startsWith(streamText)) {
131
- // Handle cumulative partial payloads where nextText already includes prior text.
132
- streamText = nextText;
133
- return;
134
- }
135
- if (streamText.endsWith(nextText)) {
136
- return;
137
- }
138
- streamText += nextText;
126
+ const mergeIntoStreamText = (nextText: string) => {
127
+ streamText = mergeStreamingText(streamText, nextText);
139
128
  };
140
129
 
141
- const queueStreamingUpdate = (
142
- nextText: string,
143
- options?: { dedupeWithLastPartial?: boolean },
144
- ) => {
145
- if (!nextText) {
146
- return;
147
- }
148
- if (options?.dedupeWithLastPartial && nextText === lastPartial) {
149
- return;
150
- }
151
- if (options?.dedupeWithLastPartial) {
152
- lastPartial = nextText;
153
- }
154
- mergeStreamingText(nextText);
130
+ const enqueueStreamingFlush = () => {
155
131
  partialUpdateQueue = partialUpdateQueue.then(async () => {
156
132
  if (streamingStartPromise) {
157
133
  await streamingStartPromise;
@@ -163,7 +139,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
163
139
  };
164
140
 
165
141
  const startStreaming = () => {
166
- if (!streamingEnabled || streamingStartPromise || streaming) {
142
+ if (!streamingEnabled || streamingStartPromise || streaming || streamingCompleted) {
167
143
  return;
168
144
  }
169
145
  streamingStartPromise = (async () => {
@@ -198,6 +174,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
198
174
  text = buildMentionedCardContent(mentionTargets, text);
199
175
  }
200
176
  await streaming.close(normalizeFeishuMarkdownLinks(text));
177
+ streamingCompleted = true;
201
178
  }
202
179
  streaming = null;
203
180
  streamingStartPromise = null;
@@ -247,7 +224,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
247
224
  if (info?.kind === "block") {
248
225
  // Some runtimes emit block payloads without onPartial/final callbacks.
249
226
  // Mirror block text into streamText so onIdle close still sends content.
250
- queueStreamingUpdate(text);
227
+ mergeIntoStreamText(text);
228
+ enqueueStreamingFlush();
251
229
  }
252
230
  if (info?.kind === "final") {
253
231
  streamText = text;
@@ -256,6 +234,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
256
234
  return;
257
235
  }
258
236
 
237
+ // Streaming card already delivered the content — skip regular send to
238
+ // avoid a duplicate card when the runtime delivers a second payload
239
+ // after the streaming session has closed (#399).
240
+ if (streamingCompleted) {
241
+ return;
242
+ }
243
+
259
244
  let first = true;
260
245
  if (useCard) {
261
246
  for (const chunk of core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)) {
@@ -312,10 +297,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
312
297
  onPartialReply: streamingEnabled
313
298
  ? (payload: ReplyPayload) => {
314
299
  const partialText = normalizeFeishuMarkdownLinks(payload.text ?? "");
315
- if (!partialText) {
300
+ if (!partialText || partialText === lastPartial) {
316
301
  return;
317
302
  }
318
- queueStreamingUpdate(partialText, { dedupeWithLastPartial: true });
303
+ lastPartial = partialText;
304
+ // Partials are cumulative — replace streamText directly instead of
305
+ // merging, which avoids duplication when segment boundaries shift
306
+ // (e.g. after a tool call mid-stream).
307
+ streamText = partialText;
308
+ enqueueStreamingFlush();
319
309
  }
320
310
  : undefined,
321
311
  },
@@ -44,6 +44,21 @@ async function getToken(creds: Credentials): Promise<string> {
44
44
  return data.tenant_access_token;
45
45
  }
46
46
 
47
+ /**
48
+ * Find the length of the longest suffix of `a` that equals a prefix of `b`.
49
+ * Used to detect partial overlaps between consecutive streaming segments.
50
+ * Capped at 500 chars to keep cost bounded.
51
+ */
52
+ function findOverlapLength(a: string, b: string): number {
53
+ const max = Math.min(a.length, b.length, 500);
54
+ for (let len = max; len > 0; len--) {
55
+ if (a.endsWith(b.slice(0, len))) {
56
+ return len;
57
+ }
58
+ }
59
+ return 0;
60
+ }
61
+
47
62
  export function mergeStreamingText(
48
63
  previousText: string | undefined,
49
64
  nextText: string | undefined,
@@ -59,7 +74,14 @@ export function mergeStreamingText(
59
74
  if (previous.includes(next)) {
60
75
  return previous;
61
76
  }
62
- // Fallback for fragmented partial chunks: append to avoid losing tokens.
77
+ // Detect partial overlap: the end of previous matches the start of next.
78
+ // This happens when partials are cumulative within a segment but don't
79
+ // include prior segments (e.g. after a tool call mid-stream).
80
+ const overlap = findOverlapLength(previous, next);
81
+ if (overlap > 0) {
82
+ return previous + next.slice(overlap);
83
+ }
84
+ // No overlap — truly incremental chunk, just append.
63
85
  return `${previous}${next}`;
64
86
  }
65
87
 
@@ -147,30 +169,30 @@ export class FeishuStreamingSession {
147
169
  }
148
170
 
149
171
  async update(text: string): Promise<void> {
150
- if (!this.state || this.closed) {
172
+ if (!this.state || this.closed || !text) {
151
173
  return;
152
174
  }
153
- const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
154
- if (!mergedInput || mergedInput === this.state.currentText) {
175
+ // Caller sends cumulative full text — no merge needed, just throttle.
176
+ if (text === this.state.currentText) {
155
177
  return;
156
178
  }
157
179
  const now = Date.now();
158
180
  if (now - this.lastUpdateTime < this.updateThrottleMs) {
159
- this.pendingText = mergedInput;
181
+ this.pendingText = text;
160
182
  return;
161
183
  }
162
184
  this.pendingText = null;
163
185
  this.lastUpdateTime = now;
164
186
 
187
+ const toSend = text;
165
188
  this.queue = this.queue.then(async () => {
166
189
  if (!this.state || this.closed) {
167
190
  return;
168
191
  }
169
- const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
170
- if (!mergedText || mergedText === this.state.currentText) {
192
+ if (toSend === this.state.currentText) {
171
193
  return;
172
194
  }
173
- this.state.currentText = mergedText;
195
+ this.state.currentText = toSend;
174
196
  this.state.sequence += 1;
175
197
  const apiBase = resolveApiBase(this.creds.domain);
176
198
  await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
@@ -180,7 +202,7 @@ export class FeishuStreamingSession {
180
202
  "Content-Type": "application/json",
181
203
  },
182
204
  body: JSON.stringify({
183
- content: mergedText,
205
+ content: toSend,
184
206
  sequence: this.state.sequence,
185
207
  uuid: `s_${this.state.cardId}_${this.state.sequence}`,
186
208
  }),
@@ -196,8 +218,9 @@ export class FeishuStreamingSession {
196
218
  this.closed = true;
197
219
  await this.queue;
198
220
 
199
- const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
200
- const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
221
+ // finalText is authoritative when provided (from deliver kind=final).
222
+ // Fall back to pending or current text when closing via onIdle.
223
+ const text = finalText || this.pendingText || this.state.currentText;
201
224
  const apiBase = resolveApiBase(this.creds.domain);
202
225
 
203
226
  if (text && text !== this.state.currentText) {
@@ -3,26 +3,33 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { TaskClient } from "./common.js";
5
5
  import {
6
+ TASK_COMMENT_UPDATE_FIELD_VALUES,
6
7
  TASKLIST_UPDATE_FIELD_VALUES,
7
8
  TASK_UPDATE_FIELD_VALUES,
8
9
  type AddTaskToTasklistParams,
9
10
  type AddTasklistMembersParams,
11
+ type CreateTaskCommentParams,
10
12
  type CreateTasklistParams,
11
- CreateSubtaskParams,
12
- CreateTaskParams,
13
- DeleteTaskAttachmentParams,
14
- GetTaskAttachmentParams,
15
- GetTaskParams,
13
+ type CreateSubtaskParams,
14
+ type CreateTaskParams,
15
+ type DeleteTaskAttachmentParams,
16
+ type DeleteTaskCommentParams,
17
+ type GetTaskAttachmentParams,
18
+ type GetTaskCommentParams,
19
+ type GetTaskParams,
16
20
  type GetTasklistParams,
17
- ListTaskAttachmentsParams,
21
+ type ListTaskAttachmentsParams,
22
+ type ListTaskCommentsParams,
18
23
  type ListTasklistsParams,
19
24
  type RemoveTaskFromTasklistParams,
20
25
  type RemoveTasklistMembersParams,
21
- TaskUpdateTask,
26
+ type TaskCommentPatchComment,
27
+ type TaskUpdateTask,
22
28
  type TasklistPatchTasklist,
23
- UploadTaskAttachmentParams,
29
+ type UploadTaskAttachmentParams,
30
+ type UpdateTaskCommentParams,
24
31
  type UpdateTasklistParams,
25
- UpdateTaskParams,
32
+ type UpdateTaskParams,
26
33
  } from "./schemas.js";
27
34
  import {
28
35
  DEFAULT_TASK_ATTACHMENT_FILENAME,
@@ -36,6 +43,7 @@ import { getFeishuRuntime } from "../runtime.js";
36
43
  import { runTaskApiCall } from "./common.js";
37
44
 
38
45
  const SUPPORTED_PATCH_FIELDS = new Set<string>(TASK_UPDATE_FIELD_VALUES);
46
+ const SUPPORTED_COMMENT_PATCH_FIELDS = new Set<string>(TASK_COMMENT_UPDATE_FIELD_VALUES);
39
47
  const SUPPORTED_TASKLIST_PATCH_FIELDS = new Set<string>(TASKLIST_UPDATE_FIELD_VALUES);
40
48
 
41
49
  function omitUndefined<T extends Record<string, unknown>>(obj: T): T {
@@ -50,10 +58,16 @@ function inferUpdateFields(task: TaskUpdateTask): string[] {
50
58
  );
51
59
  }
52
60
 
61
+ function inferCommentUpdateFields(comment: TaskCommentPatchComment): string[] {
62
+ return Object.keys(comment).filter((field) =>
63
+ SUPPORTED_COMMENT_PATCH_FIELDS.has(field),
64
+ );
65
+ }
66
+
53
67
  function ensureSupportedUpdateFields(
54
68
  updateFields: string[],
55
69
  supported: Set<string>,
56
- resource: "task" | "tasklist",
70
+ resource: "task" | "comment" | "tasklist",
57
71
  ) {
58
72
  const invalid = updateFields.filter((field) => !supported.has(field));
59
73
  if (invalid.length > 0) {
@@ -102,6 +116,20 @@ function formatTasklist(tasklist: Record<string, unknown> | undefined) {
102
116
  };
103
117
  }
104
118
 
119
+ function formatComment(comment: Record<string, unknown> | undefined) {
120
+ if (!comment) return undefined;
121
+ return {
122
+ id: comment.id,
123
+ content: comment.content,
124
+ creator: comment.creator,
125
+ reply_to_comment_id: comment.reply_to_comment_id,
126
+ created_at: comment.created_at,
127
+ updated_at: comment.updated_at,
128
+ resource_type: comment.resource_type,
129
+ resource_id: comment.resource_id,
130
+ };
131
+ }
132
+
105
133
  function formatAttachment(attachment: Record<string, unknown> | undefined) {
106
134
  if (!attachment) return undefined;
107
135
  return {
@@ -307,6 +335,113 @@ export async function getTask(client: TaskClient, params: GetTaskParams) {
307
335
  };
308
336
  }
309
337
 
338
+ export async function createTaskComment(client: TaskClient, params: CreateTaskCommentParams) {
339
+ const res = await runTaskApiCall("task.v2.comment.create", () =>
340
+ client.task.v2.comment.create({
341
+ data: omitUndefined({
342
+ content: params.content,
343
+ reply_to_comment_id: params.reply_to_comment_id,
344
+ resource_type: "task",
345
+ resource_id: params.task_guid,
346
+ }),
347
+ params: omitUndefined({
348
+ user_id_type: params.user_id_type,
349
+ }),
350
+ }),
351
+ );
352
+
353
+ return {
354
+ comment: formatComment((res.data?.comment ?? undefined) as Record<string, unknown> | undefined),
355
+ };
356
+ }
357
+
358
+ export async function listTaskComments(client: TaskClient, params: ListTaskCommentsParams) {
359
+ const res = await runTaskApiCall("task.v2.comment.list", () =>
360
+ client.task.v2.comment.list({
361
+ params: omitUndefined({
362
+ resource_type: "task",
363
+ resource_id: params.task_guid,
364
+ page_size: params.page_size,
365
+ page_token: params.page_token,
366
+ direction: params.direction,
367
+ user_id_type: params.user_id_type,
368
+ }),
369
+ }),
370
+ );
371
+
372
+ const items = (res.data?.items ?? []) as Record<string, unknown>[];
373
+
374
+ return {
375
+ items: items.map((item) => formatComment(item)),
376
+ page_token: res.data?.page_token,
377
+ has_more: res.data?.has_more,
378
+ };
379
+ }
380
+
381
+ export async function getTaskComment(client: TaskClient, params: GetTaskCommentParams) {
382
+ const res = await runTaskApiCall("task.v2.comment.get", () =>
383
+ client.task.v2.comment.get({
384
+ path: { comment_id: params.comment_id },
385
+ params: omitUndefined({
386
+ user_id_type: params.user_id_type,
387
+ }),
388
+ }),
389
+ );
390
+
391
+ return {
392
+ comment: formatComment((res.data?.comment ?? undefined) as Record<string, unknown> | undefined),
393
+ };
394
+ }
395
+
396
+ export async function updateTaskComment(client: TaskClient, params: UpdateTaskCommentParams) {
397
+ const comment = omitUndefined(params.comment as Record<string, unknown>) as TaskCommentPatchComment;
398
+ const updateFields = params.update_fields?.length
399
+ ? params.update_fields
400
+ : inferCommentUpdateFields(comment);
401
+
402
+ if (params.update_fields?.length) {
403
+ ensureSupportedUpdateFields(updateFields, SUPPORTED_COMMENT_PATCH_FIELDS, "comment");
404
+ }
405
+
406
+ if (Object.keys(comment).length === 0) {
407
+ throw new Error("comment update payload is empty");
408
+ }
409
+ if (updateFields.length === 0) {
410
+ throw new Error("no valid update_fields provided or inferred from comment payload");
411
+ }
412
+
413
+ const res = await runTaskApiCall("task.v2.comment.patch", () =>
414
+ client.task.v2.comment.patch({
415
+ path: { comment_id: params.comment_id },
416
+ data: {
417
+ comment,
418
+ update_fields: updateFields,
419
+ },
420
+ params: omitUndefined({
421
+ user_id_type: params.user_id_type,
422
+ }),
423
+ }),
424
+ );
425
+
426
+ return {
427
+ comment: formatComment((res.data?.comment ?? undefined) as Record<string, unknown> | undefined),
428
+ update_fields: updateFields,
429
+ };
430
+ }
431
+
432
+ export async function deleteTaskComment(client: TaskClient, params: DeleteTaskCommentParams) {
433
+ await runTaskApiCall("task.v2.comment.delete", () =>
434
+ client.task.v2.comment.delete({
435
+ path: { comment_id: params.comment_id },
436
+ }),
437
+ );
438
+
439
+ return {
440
+ success: true,
441
+ comment_id: params.comment_id,
442
+ };
443
+ }
444
+
310
445
  export async function getTasklist(client: TaskClient, params: GetTasklistParams) {
311
446
  const res = await runTaskApiCall("task.v2.tasklist.get", () =>
312
447
  client.task.v2.tasklist.get({