md2wechat-mcp 0.2.0 → 0.2.1

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.
package/dist/tools.js CHANGED
@@ -3,7 +3,8 @@ import { saveHtmlCache } from "./cache.js";
3
3
  import { openFileInBrowser } from "./browser.js";
4
4
  import { THEME_NAMES, THEMES } from "./themes.js";
5
5
  import { readFile } from "node:fs/promises";
6
- import { getAccessToken, draftAdd, draftUpdate, draftDelete, draftBatchGet, uploadImage } from "./wechat-api.js";
6
+ import { basename, dirname, extname, resolve } from "node:path";
7
+ import { getAccessToken, draftAdd, draftUpdate, draftDelete, draftBatchGet, uploadImage, addMaterial } from "./wechat-api.js";
7
8
  export function listThemesPayload() {
8
9
  return {
9
10
  themes: [
@@ -21,6 +22,13 @@ function invalidThemeResult(theme) {
21
22
  isError: true
22
23
  };
23
24
  }
25
+ const FONT_SIZE_PRESETS = ["small", "medium", "large"];
26
+ function invalidFontSizePresetResult(preset) {
27
+ return {
28
+ content: [{ type: "text", text: `Invalid font_size_preset: ${String(preset)}. Available: ${FONT_SIZE_PRESETS.join(", ")}` }],
29
+ isError: true
30
+ };
31
+ }
24
32
  function validateMarkdown(markdown) {
25
33
  return typeof markdown === "string" && markdown.trim().length > 0;
26
34
  }
@@ -30,6 +38,404 @@ function validateMarkdownPath(path) {
30
38
  function validateCachePath(path) {
31
39
  return typeof path === "string" && path.trim().length > 0;
32
40
  }
41
+ function getFileExtension(inputPath) {
42
+ const cleaned = inputPath.split(/[?#]/)[0] ?? inputPath;
43
+ return extname(cleaned).toLowerCase().replace(".", "");
44
+ }
45
+ function isRemoteUrl(url) {
46
+ return /^https?:\/\//i.test(url);
47
+ }
48
+ function isWhitespace(char) {
49
+ return /\s/.test(char);
50
+ }
51
+ function mergeRanges(ranges) {
52
+ if (ranges.length === 0)
53
+ return [];
54
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
55
+ const merged = [sorted[0]];
56
+ for (let i = 1; i < sorted.length; i += 1) {
57
+ const current = sorted[i];
58
+ const last = merged[merged.length - 1];
59
+ if (current.start <= last.end) {
60
+ last.end = Math.max(last.end, current.end);
61
+ }
62
+ else {
63
+ merged.push({ ...current });
64
+ }
65
+ }
66
+ return merged;
67
+ }
68
+ function computeExcludedRanges(markdown) {
69
+ const ranges = [];
70
+ const lines = markdown.split("\n");
71
+ let offset = 0;
72
+ let inFence = false;
73
+ let fenceChar = "";
74
+ let fenceLen = 0;
75
+ let fenceStart = -1;
76
+ for (const line of lines) {
77
+ const trimmedLeft = line.replace(/^\s{0,3}/, "");
78
+ const fenceMatch = trimmedLeft.match(/^(`{3,}|~{3,})/);
79
+ if (fenceMatch) {
80
+ const marker = fenceMatch[1];
81
+ const markerChar = marker[0];
82
+ const markerLen = marker.length;
83
+ if (!inFence) {
84
+ inFence = true;
85
+ fenceChar = markerChar;
86
+ fenceLen = markerLen;
87
+ fenceStart = offset;
88
+ }
89
+ else if (markerChar === fenceChar && markerLen >= fenceLen) {
90
+ ranges.push({ start: fenceStart, end: offset + line.length + 1 });
91
+ inFence = false;
92
+ fenceChar = "";
93
+ fenceLen = 0;
94
+ fenceStart = -1;
95
+ }
96
+ }
97
+ offset += line.length + 1;
98
+ }
99
+ if (inFence && fenceStart >= 0) {
100
+ ranges.push({ start: fenceStart, end: markdown.length });
101
+ }
102
+ // inline code spans outside fenced code
103
+ const fenceRanges = mergeRanges(ranges);
104
+ let i = 0;
105
+ let fenceIdx = 0;
106
+ while (i < markdown.length) {
107
+ const activeFence = fenceRanges[fenceIdx];
108
+ if (activeFence && i >= activeFence.start) {
109
+ i = Math.max(i, activeFence.end);
110
+ fenceIdx += 1;
111
+ continue;
112
+ }
113
+ if (markdown[i] !== "`") {
114
+ i += 1;
115
+ continue;
116
+ }
117
+ let runLen = 1;
118
+ while (i + runLen < markdown.length && markdown[i + runLen] === "`")
119
+ runLen += 1;
120
+ const start = i;
121
+ i += runLen;
122
+ let close = -1;
123
+ while (i < markdown.length) {
124
+ if (markdown[i] !== "`") {
125
+ i += 1;
126
+ continue;
127
+ }
128
+ let closeRun = 1;
129
+ while (i + closeRun < markdown.length && markdown[i + closeRun] === "`")
130
+ closeRun += 1;
131
+ if (closeRun === runLen) {
132
+ close = i + runLen;
133
+ break;
134
+ }
135
+ i += closeRun;
136
+ }
137
+ if (close > 0) {
138
+ ranges.push({ start, end: close });
139
+ i = close;
140
+ }
141
+ else {
142
+ i = start + runLen;
143
+ }
144
+ }
145
+ return mergeRanges(ranges);
146
+ }
147
+ function findRangeAt(ranges, index) {
148
+ let left = 0;
149
+ let right = ranges.length - 1;
150
+ while (left <= right) {
151
+ const mid = Math.floor((left + right) / 2);
152
+ const range = ranges[mid];
153
+ if (index < range.start)
154
+ right = mid - 1;
155
+ else if (index >= range.end)
156
+ left = mid + 1;
157
+ else
158
+ return range;
159
+ }
160
+ return undefined;
161
+ }
162
+ function readEscapedChar(input, cursor, escapable) {
163
+ if (input[cursor] !== "\\" || cursor + 1 >= input.length)
164
+ return undefined;
165
+ const nextChar = input[cursor + 1];
166
+ if (!escapable.has(nextChar))
167
+ return undefined;
168
+ return { value: nextChar, next: cursor + 2 };
169
+ }
170
+ function escapeForQuotedTitle(value, quote) {
171
+ const escapedSlash = value.replaceAll("\\", "\\\\");
172
+ return escapedSlash.replaceAll(quote, `\\${quote}`);
173
+ }
174
+ function parseMarkdownImageTokens(markdown) {
175
+ const tokens = [];
176
+ const excludedRanges = computeExcludedRanges(markdown);
177
+ let i = 0;
178
+ while (i < markdown.length) {
179
+ const blocked = findRangeAt(excludedRanges, i);
180
+ if (blocked) {
181
+ i = blocked.end;
182
+ continue;
183
+ }
184
+ if (markdown[i] !== "!" || markdown[i + 1] !== "[") {
185
+ i += 1;
186
+ continue;
187
+ }
188
+ const start = i;
189
+ let cursor = i + 2;
190
+ let alt = "";
191
+ let altClosed = false;
192
+ while (cursor < markdown.length) {
193
+ const c = markdown[cursor];
194
+ const escaped = readEscapedChar(markdown, cursor, new Set(["\\", "]", "[", "(", ")", "\"", "'"]));
195
+ if (escaped) {
196
+ alt += escaped.value;
197
+ cursor = escaped.next;
198
+ continue;
199
+ }
200
+ if (c === "]") {
201
+ altClosed = true;
202
+ cursor += 1;
203
+ break;
204
+ }
205
+ alt += c;
206
+ cursor += 1;
207
+ }
208
+ if (!altClosed || markdown[cursor] !== "(") {
209
+ i += 1;
210
+ continue;
211
+ }
212
+ cursor += 1;
213
+ while (cursor < markdown.length && isWhitespace(markdown[cursor]))
214
+ cursor += 1;
215
+ let src = "";
216
+ if (markdown[cursor] === "<") {
217
+ cursor += 1;
218
+ while (cursor < markdown.length) {
219
+ const c = markdown[cursor];
220
+ const escaped = readEscapedChar(markdown, cursor, new Set(["\\", ">", "<", "(", ")", "\"", "'", " "]));
221
+ if (escaped) {
222
+ src += escaped.value;
223
+ cursor = escaped.next;
224
+ continue;
225
+ }
226
+ if (c === ">") {
227
+ cursor += 1;
228
+ break;
229
+ }
230
+ src += c;
231
+ cursor += 1;
232
+ }
233
+ }
234
+ else {
235
+ let depth = 0;
236
+ while (cursor < markdown.length) {
237
+ const c = markdown[cursor];
238
+ const escaped = readEscapedChar(markdown, cursor, new Set(["\\", "(", ")", " "]));
239
+ if (escaped) {
240
+ src += escaped.value;
241
+ cursor = escaped.next;
242
+ continue;
243
+ }
244
+ if (c === "(") {
245
+ depth += 1;
246
+ src += c;
247
+ cursor += 1;
248
+ continue;
249
+ }
250
+ if (c === ")") {
251
+ if (depth === 0)
252
+ break;
253
+ depth -= 1;
254
+ src += c;
255
+ cursor += 1;
256
+ continue;
257
+ }
258
+ if (isWhitespace(c) && depth === 0)
259
+ break;
260
+ src += c;
261
+ cursor += 1;
262
+ }
263
+ }
264
+ while (cursor < markdown.length && isWhitespace(markdown[cursor]))
265
+ cursor += 1;
266
+ let title;
267
+ let titleQuote;
268
+ if (markdown[cursor] === "\"" || markdown[cursor] === "'") {
269
+ titleQuote = markdown[cursor];
270
+ cursor += 1;
271
+ let titleText = "";
272
+ while (cursor < markdown.length) {
273
+ const c = markdown[cursor];
274
+ const escaped = readEscapedChar(markdown, cursor, new Set(["\\", "\"", "'"]));
275
+ if (escaped) {
276
+ titleText += escaped.value;
277
+ cursor = escaped.next;
278
+ continue;
279
+ }
280
+ if (c === titleQuote) {
281
+ cursor += 1;
282
+ break;
283
+ }
284
+ titleText += c;
285
+ cursor += 1;
286
+ }
287
+ title = titleText;
288
+ while (cursor < markdown.length && isWhitespace(markdown[cursor]))
289
+ cursor += 1;
290
+ }
291
+ if (markdown[cursor] !== ")" || !src) {
292
+ i += 1;
293
+ continue;
294
+ }
295
+ tokens.push({ start, end: cursor + 1, alt, src, title, titleQuote });
296
+ i = cursor + 1;
297
+ }
298
+ return tokens;
299
+ }
300
+ function replaceMarkdownImageSources(markdown, srcMap) {
301
+ const tokens = parseMarkdownImageTokens(markdown);
302
+ if (tokens.length === 0)
303
+ return markdown;
304
+ let out = "";
305
+ let last = 0;
306
+ for (const token of tokens) {
307
+ out += markdown.slice(last, token.start);
308
+ const mapped = srcMap.get(token.src);
309
+ if (!mapped) {
310
+ out += markdown.slice(token.start, token.end);
311
+ last = token.end;
312
+ continue;
313
+ }
314
+ const quote = token.titleQuote ?? "\"";
315
+ const titlePart = token.title ? ` ${quote}${escapeForQuotedTitle(token.title, quote)}${quote}` : "";
316
+ out += `![${token.alt}](${mapped}${titlePart})`;
317
+ last = token.end;
318
+ }
319
+ out += markdown.slice(last);
320
+ return out;
321
+ }
322
+ function removeMarkdownSlice(markdown, start, end) {
323
+ const removed = `${markdown.slice(0, start)}${markdown.slice(end)}`;
324
+ return removed.replace(/\n{3,}/g, "\n\n");
325
+ }
326
+ function removeLeadingAtxH1(markdown) {
327
+ const normalized = markdown.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
328
+ const lines = normalized.split("\n");
329
+ let index = 0;
330
+ while (index < lines.length && !lines[index]?.trim()) {
331
+ index += 1;
332
+ }
333
+ if (lines[index]?.trim() === "---") {
334
+ index += 1;
335
+ while (index < lines.length && lines[index]?.trim() !== "---") {
336
+ index += 1;
337
+ }
338
+ if (index < lines.length) {
339
+ index += 1;
340
+ }
341
+ }
342
+ while (index < lines.length && !lines[index]?.trim()) {
343
+ index += 1;
344
+ }
345
+ const line = lines[index];
346
+ if (!line || !/^\s{0,3}#\s+(.+?)\s*#*\s*$/u.test(line)) {
347
+ return markdown;
348
+ }
349
+ lines.splice(index, 1);
350
+ while (index < lines.length && !lines[index]?.trim()) {
351
+ lines.splice(index, 1);
352
+ }
353
+ return lines.join("\n");
354
+ }
355
+ function extractLeadingAtxH1(markdown) {
356
+ const normalized = markdown.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
357
+ const lines = normalized.split("\n");
358
+ let index = 0;
359
+ while (index < lines.length && !lines[index]?.trim()) {
360
+ index += 1;
361
+ }
362
+ if (lines[index]?.trim() === "---") {
363
+ index += 1;
364
+ while (index < lines.length && lines[index]?.trim() !== "---") {
365
+ index += 1;
366
+ }
367
+ if (index < lines.length) {
368
+ index += 1;
369
+ }
370
+ }
371
+ while (index < lines.length && !lines[index]?.trim()) {
372
+ index += 1;
373
+ }
374
+ const line = lines[index];
375
+ const match = line?.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u);
376
+ return match?.[1]?.trim() || undefined;
377
+ }
378
+ function inferArticleTitle(rawArticleTitle, markdown, markdownPath) {
379
+ if (typeof rawArticleTitle === "string" && rawArticleTitle.trim()) {
380
+ return rawArticleTitle.trim();
381
+ }
382
+ const headingTitle = extractLeadingAtxH1(markdown);
383
+ if (headingTitle) {
384
+ return headingTitle;
385
+ }
386
+ if (typeof markdownPath === "string" && markdownPath.trim()) {
387
+ const base = basename(markdownPath.trim(), extname(markdownPath.trim())).trim();
388
+ if (base) {
389
+ return base;
390
+ }
391
+ }
392
+ return undefined;
393
+ }
394
+ async function resolveMarkdownSource(args) {
395
+ const directMarkdown = args.markdown;
396
+ const markdownPath = args.markdown_path;
397
+ if (directMarkdown !== undefined) {
398
+ if (!validateMarkdown(directMarkdown)) {
399
+ return {
400
+ content: [{ type: "text", text: "markdown must be a non-empty string when provided." }],
401
+ isError: true
402
+ };
403
+ }
404
+ return { markdown: directMarkdown, markdownPath: typeof markdownPath === "string" ? markdownPath : undefined };
405
+ }
406
+ if (validateMarkdownPath(markdownPath)) {
407
+ try {
408
+ const markdown = await readFile(markdownPath, "utf8");
409
+ if (!validateMarkdown(markdown)) {
410
+ return {
411
+ content: [{ type: "text", text: "markdown_path points to an empty markdown file." }],
412
+ isError: true
413
+ };
414
+ }
415
+ return { markdown, markdownPath };
416
+ }
417
+ catch (error) {
418
+ return {
419
+ content: [{ type: "text", text: `Failed to read markdown_path: ${error.message}` }],
420
+ isError: true
421
+ };
422
+ }
423
+ }
424
+ return {
425
+ content: [{ type: "text", text: "Provide one markdown source: markdown or markdown_path." }],
426
+ isError: true
427
+ };
428
+ }
429
+ function isToolResult(value) {
430
+ return "content" in value;
431
+ }
432
+ const PERMANENT_MATERIAL_TYPES = ["image", "voice", "video", "thumb"];
433
+ const MATERIAL_EXTENSION_RULES = {
434
+ image: ["bmp", "png", "jpeg", "jpg", "gif"],
435
+ voice: ["mp3", "wma", "wav", "amr"],
436
+ video: ["mp4"],
437
+ thumb: ["jpg", "jpeg"]
438
+ };
33
439
  export async function handleToolCall(name, args) {
34
440
  if (name === "list_wechat_themes") {
35
441
  return {
@@ -72,24 +478,29 @@ export async function handleToolCall(name, args) {
72
478
  isError: true
73
479
  };
74
480
  }
481
+ markdown = removeLeadingAtxH1(markdown);
75
482
  const theme = args.theme ?? "default";
76
483
  if (typeof theme !== "string" || !(theme in THEMES)) {
77
484
  return invalidThemeResult(theme);
78
485
  }
486
+ const fontSizePresetArg = args.font_size_preset ?? "medium";
487
+ if (typeof fontSizePresetArg !== "string" || !FONT_SIZE_PRESETS.includes(fontSizePresetArg)) {
488
+ return invalidFontSizePresetResult(fontSizePresetArg);
489
+ }
490
+ const fontSizePreset = fontSizePresetArg;
79
491
  const title = typeof args.title === "string" ? args.title : undefined;
80
- // Upload local images if access_token is provided
81
492
  const accessToken = typeof args.access_token === "string" ? args.access_token.trim() : undefined;
493
+ // Upload local images if access_token is provided
82
494
  if (accessToken) {
83
- const baseDir = typeof markdownPath === "string" ? markdownPath.replace(/[^/\\]+$/, "") : "";
84
- const localImagePattern = /!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g;
495
+ const baseDir = typeof markdownPath === "string" ? dirname(markdownPath) : process.cwd();
85
496
  const uploadErrors = [];
86
497
  const uploadCache = new Map();
87
- const matches = [...markdown.matchAll(localImagePattern)];
88
- for (const match of matches) {
89
- const rawPath = match[2];
90
- if (!rawPath || uploadCache.has(rawPath))
498
+ const imageTokens = parseMarkdownImageTokens(markdown);
499
+ for (const token of imageTokens) {
500
+ const rawPath = token.src;
501
+ if (!rawPath || uploadCache.has(rawPath) || isRemoteUrl(rawPath))
91
502
  continue;
92
- const absPath = rawPath.startsWith("/") ? rawPath : `${baseDir}${rawPath}`;
503
+ const absPath = rawPath.startsWith("/") ? rawPath : resolve(baseDir, rawPath);
93
504
  try {
94
505
  const result = await uploadImage(accessToken, absPath);
95
506
  uploadCache.set(rawPath, result.url);
@@ -98,9 +509,7 @@ export async function handleToolCall(name, args) {
98
509
  uploadErrors.push(`${rawPath}: ${error.message}`);
99
510
  }
100
511
  }
101
- for (const [localPath, cdnUrl] of uploadCache) {
102
- markdown = markdown.replaceAll(`](${localPath})`, `](${cdnUrl})`);
103
- }
512
+ markdown = replaceMarkdownImageSources(markdown, uploadCache);
104
513
  if (uploadErrors.length > 0) {
105
514
  return {
106
515
  content: [{ type: "text", text: `Image upload failed:\n${uploadErrors.join("\n")}` }],
@@ -108,10 +517,14 @@ export async function handleToolCall(name, args) {
108
517
  };
109
518
  }
110
519
  }
111
- const html = parseMarkdown(markdown, theme, title);
520
+ const html = parseMarkdown(markdown, theme, title, fontSizePreset);
112
521
  const savedPath = saveHtmlCache(html);
522
+ const visibleMetaLines = [`cacheHtmlPath=${savedPath}`];
113
523
  return {
114
- content: [{ type: "text", text: html }],
524
+ content: [
525
+ { type: "text", text: html },
526
+ { type: "text", text: visibleMetaLines.join("\n") }
527
+ ],
115
528
  meta: { cacheHtmlPath: savedPath }
116
529
  };
117
530
  }
@@ -178,6 +591,118 @@ export async function handleToolCall(name, args) {
178
591
  return { content: [{ type: "text", text: error.message }], isError: true };
179
592
  }
180
593
  }
594
+ if (name === "wechat_markdown_to_draft") {
595
+ const accessToken = args.access_token;
596
+ const rawArticleTitle = args.article_title;
597
+ const author = typeof args.author === "string" ? args.author : undefined;
598
+ const digest = typeof args.digest === "string" ? args.digest : undefined;
599
+ const contentSourceUrl = typeof args.content_source_url === "string" ? args.content_source_url : undefined;
600
+ const thumbMediaIdInput = typeof args.thumb_media_id === "string" ? args.thumb_media_id : undefined;
601
+ const needOpenComment = args.need_open_comment;
602
+ const onlyFansCanComment = args.only_fans_can_comment;
603
+ if (typeof accessToken !== "string" || !accessToken.trim()) {
604
+ return { content: [{ type: "text", text: "access_token is required." }], isError: true };
605
+ }
606
+ if (needOpenComment !== undefined && needOpenComment !== 0 && needOpenComment !== 1) {
607
+ return { content: [{ type: "text", text: "need_open_comment must be 0 or 1." }], isError: true };
608
+ }
609
+ if (onlyFansCanComment !== undefined && onlyFansCanComment !== 0 && onlyFansCanComment !== 1) {
610
+ return { content: [{ type: "text", text: "only_fans_can_comment must be 0 or 1." }], isError: true };
611
+ }
612
+ const source = await resolveMarkdownSource(args);
613
+ if (isToolResult(source)) {
614
+ return source;
615
+ }
616
+ const articleTitle = inferArticleTitle(rawArticleTitle, source.markdown, source.markdownPath);
617
+ if (!articleTitle) {
618
+ return {
619
+ content: [{ type: "text", text: "article_title is required when markdown has no leading H1 and markdown_path is unavailable." }],
620
+ isError: true
621
+ };
622
+ }
623
+ let markdownForConvert = source.markdown;
624
+ const markdownPath = source.markdownPath;
625
+ const baseDir = markdownPath ? dirname(markdownPath) : process.cwd();
626
+ const imageTokens = parseMarkdownImageTokens(markdownForConvert);
627
+ let designatedCoverMediaId;
628
+ let firstImageCoverMediaId;
629
+ if (!thumbMediaIdInput) {
630
+ const designatedCoverToken = imageTokens.find((token) => token.title?.trim() === "封面");
631
+ if (designatedCoverToken && !isRemoteUrl(designatedCoverToken.src)) {
632
+ const coverExt = getFileExtension(designatedCoverToken.src);
633
+ if (coverExt === "jpg" || coverExt === "jpeg") {
634
+ const coverPath = designatedCoverToken.src.startsWith("/") ? designatedCoverToken.src : resolve(baseDir, designatedCoverToken.src);
635
+ try {
636
+ const coverResult = await addMaterial(accessToken, "thumb", coverPath);
637
+ designatedCoverMediaId = coverResult.media_id;
638
+ markdownForConvert = removeMarkdownSlice(markdownForConvert, designatedCoverToken.start, designatedCoverToken.end);
639
+ }
640
+ catch (error) {
641
+ return { content: [{ type: "text", text: `Cover upload failed: ${error.message}` }], isError: true };
642
+ }
643
+ }
644
+ }
645
+ if (!designatedCoverMediaId) {
646
+ const firstLocalImage = imageTokens.find((token) => !isRemoteUrl(token.src));
647
+ if (firstLocalImage) {
648
+ const ext = getFileExtension(firstLocalImage.src);
649
+ if (ext === "jpg" || ext === "jpeg") {
650
+ const firstPath = firstLocalImage.src.startsWith("/") ? firstLocalImage.src : resolve(baseDir, firstLocalImage.src);
651
+ try {
652
+ const firstResult = await addMaterial(accessToken, "thumb", firstPath);
653
+ firstImageCoverMediaId = firstResult.media_id;
654
+ }
655
+ catch {
656
+ firstImageCoverMediaId = undefined;
657
+ }
658
+ }
659
+ }
660
+ }
661
+ }
662
+ markdownForConvert = removeLeadingAtxH1(markdownForConvert);
663
+ const convertResult = await handleToolCall("convert_markdown_to_wechat_html", {
664
+ markdown: markdownForConvert,
665
+ markdown_path: markdownPath,
666
+ theme: args.theme,
667
+ font_size_preset: args.font_size_preset,
668
+ access_token: accessToken
669
+ });
670
+ if (convertResult.isError) {
671
+ return convertResult;
672
+ }
673
+ const html = convertResult.content[0]?.text;
674
+ if (!validateMarkdown(html)) {
675
+ return {
676
+ content: [{ type: "text", text: "Failed to generate HTML content from markdown." }],
677
+ isError: true
678
+ };
679
+ }
680
+ const finalThumbMediaId = thumbMediaIdInput || designatedCoverMediaId || firstImageCoverMediaId;
681
+ const article = {
682
+ title: articleTitle,
683
+ content: html,
684
+ ...(author ? { author } : {}),
685
+ ...(digest ? { digest } : {}),
686
+ ...(contentSourceUrl ? { content_source_url: contentSourceUrl } : {}),
687
+ ...(finalThumbMediaId ? { thumb_media_id: finalThumbMediaId } : {}),
688
+ ...(needOpenComment === 0 || needOpenComment === 1 ? { need_open_comment: needOpenComment } : {}),
689
+ ...(onlyFansCanComment === 0 || onlyFansCanComment === 1 ? { only_fans_can_comment: onlyFansCanComment } : {})
690
+ };
691
+ try {
692
+ const draftResult = await draftAdd(accessToken, [article]);
693
+ return {
694
+ content: [{ type: "text", text: JSON.stringify(draftResult, null, 2) }],
695
+ meta: {
696
+ mediaId: draftResult.media_id,
697
+ cacheHtmlPath: convertResult.meta?.cacheHtmlPath,
698
+ ...(finalThumbMediaId ? { thumbMediaId: finalThumbMediaId } : {})
699
+ }
700
+ };
701
+ }
702
+ catch (error) {
703
+ return { content: [{ type: "text", text: error.message }], isError: true };
704
+ }
705
+ }
181
706
  if (name === "wechat_draft_update") {
182
707
  const accessToken = args.access_token;
183
708
  const mediaId = args.media_id;
@@ -212,7 +737,7 @@ export async function handleToolCall(name, args) {
212
737
  if (typeof filePath !== "string" || !filePath.trim()) {
213
738
  return { content: [{ type: "text", text: "file_path is required." }], isError: true };
214
739
  }
215
- const ext = filePath.toLowerCase().split(".").pop();
740
+ const ext = getFileExtension(filePath);
216
741
  if (ext !== "jpg" && ext !== "jpeg" && ext !== "png") {
217
742
  return { content: [{ type: "text", text: "Only JPG and PNG files are supported." }], isError: true };
218
743
  }
@@ -224,6 +749,43 @@ export async function handleToolCall(name, args) {
224
749
  return { content: [{ type: "text", text: error.message }], isError: true };
225
750
  }
226
751
  }
752
+ if (name === "wechat_add_material") {
753
+ const accessToken = args.access_token;
754
+ const type = args.type;
755
+ const filePath = args.file_path;
756
+ const title = typeof args.title === "string" ? args.title.trim() : undefined;
757
+ const introduction = typeof args.introduction === "string" ? args.introduction.trim() : undefined;
758
+ if (typeof accessToken !== "string" || !accessToken.trim()) {
759
+ return { content: [{ type: "text", text: "access_token is required." }], isError: true };
760
+ }
761
+ if (typeof type !== "string" || !PERMANENT_MATERIAL_TYPES.includes(type)) {
762
+ return { content: [{ type: "text", text: "type must be one of: image, voice, video, thumb." }], isError: true };
763
+ }
764
+ if (typeof filePath !== "string" || !filePath.trim()) {
765
+ return { content: [{ type: "text", text: "file_path is required." }], isError: true };
766
+ }
767
+ const ext = getFileExtension(filePath);
768
+ const allowedExt = MATERIAL_EXTENSION_RULES[type];
769
+ if (!ext || !allowedExt.includes(ext)) {
770
+ return {
771
+ content: [{ type: "text", text: `Invalid file extension for type=${type}. Allowed: ${allowedExt.join(", ")}.` }],
772
+ isError: true
773
+ };
774
+ }
775
+ if (type === "video" && (!title || !introduction)) {
776
+ return {
777
+ content: [{ type: "text", text: "For video material, both title and introduction are required." }],
778
+ isError: true
779
+ };
780
+ }
781
+ try {
782
+ const result = await addMaterial(accessToken, type, filePath, title || introduction ? { title, introduction } : undefined);
783
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
784
+ }
785
+ catch (error) {
786
+ return { content: [{ type: "text", text: error.message }], isError: true };
787
+ }
788
+ }
227
789
  if (name === "wechat_draft_batchget") {
228
790
  const accessToken = args.access_token;
229
791
  const offset = args.offset ?? 0;
@@ -48,6 +48,16 @@ export declare function draftBatchGet(accessToken: string, offset: number, count
48
48
  export declare function uploadImage(accessToken: string, filePath: string): Promise<{
49
49
  url: string;
50
50
  }>;
51
+ export type PermanentMaterialType = "image" | "voice" | "video" | "thumb";
52
+ export interface MaterialDescription {
53
+ title?: string;
54
+ introduction?: string;
55
+ }
56
+ export interface AddMaterialResult {
57
+ media_id: string;
58
+ url?: string;
59
+ }
60
+ export declare function addMaterial(accessToken: string, type: PermanentMaterialType, filePath: string, description?: MaterialDescription): Promise<AddMaterialResult>;
51
61
  export declare function draftDelete(accessToken: string, mediaId: string): Promise<{
52
62
  errcode: number;
53
63
  errmsg: string;