md2wechat-mcp 0.2.0 → 0.2.2

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,458 @@ 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
+ async function uploadLocalImageSourcesInHtml(html, accessToken, baseDir) {
323
+ const uploadErrors = [];
324
+ const uploadCache = new Map();
325
+ let uploadedCount = 0;
326
+ const imgTags = [...html.matchAll(/<img\b[^>]*\bsrc\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)[^>]*>/giu)];
327
+ if (imgTags.length === 0) {
328
+ return { html, uploadedCount };
329
+ }
330
+ const replacements = new Map();
331
+ for (const match of imgTags) {
332
+ const imgTag = match[0];
333
+ if (replacements.has(imgTag)) {
334
+ continue;
335
+ }
336
+ const srcMatch = imgTag.match(/src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/iu);
337
+ if (!srcMatch) {
338
+ continue;
339
+ }
340
+ const rawSrc = srcMatch?.[1] ?? srcMatch?.[2] ?? srcMatch?.[3];
341
+ if (!rawSrc) {
342
+ continue;
343
+ }
344
+ const normalizedSrc = rawSrc.trim();
345
+ if (!normalizedSrc || isRemoteUrl(normalizedSrc) || /^data:/iu.test(normalizedSrc)) {
346
+ continue;
347
+ }
348
+ const cached = uploadCache.get(normalizedSrc);
349
+ if (cached) {
350
+ replacements.set(imgTag, imgTag.replace(srcMatch[0], `src="${cached}"`));
351
+ continue;
352
+ }
353
+ const absPath = normalizedSrc.startsWith("/") ? normalizedSrc : resolve(baseDir, normalizedSrc);
354
+ try {
355
+ const uploaded = await uploadImage(accessToken, absPath);
356
+ uploadCache.set(normalizedSrc, uploaded.url);
357
+ uploadedCount += 1;
358
+ replacements.set(imgTag, imgTag.replace(srcMatch[0], `src="${uploaded.url}"`));
359
+ }
360
+ catch (error) {
361
+ uploadErrors.push(`${normalizedSrc}: ${error.message}`);
362
+ }
363
+ }
364
+ if (uploadErrors.length > 0) {
365
+ throw new Error(`Image upload failed:\n${uploadErrors.join("\n")}`);
366
+ }
367
+ let rewrittenHtml = html;
368
+ for (const [before, after] of replacements) {
369
+ rewrittenHtml = rewrittenHtml.split(before).join(after);
370
+ }
371
+ return { html: rewrittenHtml, uploadedCount };
372
+ }
373
+ function removeLeadingHtmlH1(html) {
374
+ return html.replace(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>\s*/iu, "");
375
+ }
376
+ function removeMarkdownSlice(markdown, start, end) {
377
+ const removed = `${markdown.slice(0, start)}${markdown.slice(end)}`;
378
+ return removed.replace(/\n{3,}/g, "\n\n");
379
+ }
380
+ function removeLeadingAtxH1(markdown) {
381
+ const normalized = markdown.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
382
+ const lines = normalized.split("\n");
383
+ let index = 0;
384
+ while (index < lines.length && !lines[index]?.trim()) {
385
+ index += 1;
386
+ }
387
+ if (lines[index]?.trim() === "---") {
388
+ index += 1;
389
+ while (index < lines.length && lines[index]?.trim() !== "---") {
390
+ index += 1;
391
+ }
392
+ if (index < lines.length) {
393
+ index += 1;
394
+ }
395
+ }
396
+ while (index < lines.length && !lines[index]?.trim()) {
397
+ index += 1;
398
+ }
399
+ const line = lines[index];
400
+ if (!line || !/^\s{0,3}#\s+(.+?)\s*#*\s*$/u.test(line)) {
401
+ return markdown;
402
+ }
403
+ lines.splice(index, 1);
404
+ while (index < lines.length && !lines[index]?.trim()) {
405
+ lines.splice(index, 1);
406
+ }
407
+ return lines.join("\n");
408
+ }
409
+ function extractLeadingAtxH1(markdown) {
410
+ const normalized = markdown.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
411
+ const lines = normalized.split("\n");
412
+ let index = 0;
413
+ while (index < lines.length && !lines[index]?.trim()) {
414
+ index += 1;
415
+ }
416
+ if (lines[index]?.trim() === "---") {
417
+ index += 1;
418
+ while (index < lines.length && lines[index]?.trim() !== "---") {
419
+ index += 1;
420
+ }
421
+ if (index < lines.length) {
422
+ index += 1;
423
+ }
424
+ }
425
+ while (index < lines.length && !lines[index]?.trim()) {
426
+ index += 1;
427
+ }
428
+ const line = lines[index];
429
+ const match = line?.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u);
430
+ return match?.[1]?.trim() || undefined;
431
+ }
432
+ function inferArticleTitle(rawArticleTitle, markdown, markdownPath) {
433
+ if (typeof rawArticleTitle === "string" && rawArticleTitle.trim()) {
434
+ return rawArticleTitle.trim();
435
+ }
436
+ const headingTitle = extractLeadingAtxH1(markdown);
437
+ if (headingTitle) {
438
+ return headingTitle;
439
+ }
440
+ if (typeof markdownPath === "string" && markdownPath.trim()) {
441
+ const base = basename(markdownPath.trim(), extname(markdownPath.trim())).trim();
442
+ if (base) {
443
+ return base;
444
+ }
445
+ }
446
+ return undefined;
447
+ }
448
+ async function resolveMarkdownSource(args) {
449
+ const directMarkdown = args.markdown;
450
+ const markdownPath = args.markdown_path;
451
+ if (directMarkdown !== undefined) {
452
+ if (!validateMarkdown(directMarkdown)) {
453
+ return {
454
+ content: [{ type: "text", text: "markdown must be a non-empty string when provided." }],
455
+ isError: true
456
+ };
457
+ }
458
+ return { markdown: directMarkdown, markdownPath: typeof markdownPath === "string" ? markdownPath : undefined };
459
+ }
460
+ if (validateMarkdownPath(markdownPath)) {
461
+ try {
462
+ const markdown = await readFile(markdownPath, "utf8");
463
+ if (!validateMarkdown(markdown)) {
464
+ return {
465
+ content: [{ type: "text", text: "markdown_path points to an empty markdown file." }],
466
+ isError: true
467
+ };
468
+ }
469
+ return { markdown, markdownPath };
470
+ }
471
+ catch (error) {
472
+ return {
473
+ content: [{ type: "text", text: `Failed to read markdown_path: ${error.message}` }],
474
+ isError: true
475
+ };
476
+ }
477
+ }
478
+ return {
479
+ content: [{ type: "text", text: "Provide one markdown source: markdown or markdown_path." }],
480
+ isError: true
481
+ };
482
+ }
483
+ function isToolResult(value) {
484
+ return "content" in value;
485
+ }
486
+ const PERMANENT_MATERIAL_TYPES = ["image", "voice", "video", "thumb"];
487
+ const MATERIAL_EXTENSION_RULES = {
488
+ image: ["bmp", "png", "jpeg", "jpg", "gif"],
489
+ voice: ["mp3", "wma", "wav", "amr"],
490
+ video: ["mp4"],
491
+ thumb: ["jpg", "jpeg"]
492
+ };
33
493
  export async function handleToolCall(name, args) {
34
494
  if (name === "list_wechat_themes") {
35
495
  return {
@@ -72,24 +532,29 @@ export async function handleToolCall(name, args) {
72
532
  isError: true
73
533
  };
74
534
  }
535
+ markdown = removeLeadingAtxH1(markdown);
75
536
  const theme = args.theme ?? "default";
76
537
  if (typeof theme !== "string" || !(theme in THEMES)) {
77
538
  return invalidThemeResult(theme);
78
539
  }
540
+ const fontSizePresetArg = args.font_size_preset ?? "medium";
541
+ if (typeof fontSizePresetArg !== "string" || !FONT_SIZE_PRESETS.includes(fontSizePresetArg)) {
542
+ return invalidFontSizePresetResult(fontSizePresetArg);
543
+ }
544
+ const fontSizePreset = fontSizePresetArg;
79
545
  const title = typeof args.title === "string" ? args.title : undefined;
80
- // Upload local images if access_token is provided
81
546
  const accessToken = typeof args.access_token === "string" ? args.access_token.trim() : undefined;
547
+ // Upload local images if access_token is provided
82
548
  if (accessToken) {
83
- const baseDir = typeof markdownPath === "string" ? markdownPath.replace(/[^/\\]+$/, "") : "";
84
- const localImagePattern = /!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g;
549
+ const baseDir = typeof markdownPath === "string" ? dirname(markdownPath) : process.cwd();
85
550
  const uploadErrors = [];
86
551
  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))
552
+ const imageTokens = parseMarkdownImageTokens(markdown);
553
+ for (const token of imageTokens) {
554
+ const rawPath = token.src;
555
+ if (!rawPath || uploadCache.has(rawPath) || isRemoteUrl(rawPath))
91
556
  continue;
92
- const absPath = rawPath.startsWith("/") ? rawPath : `${baseDir}${rawPath}`;
557
+ const absPath = rawPath.startsWith("/") ? rawPath : resolve(baseDir, rawPath);
93
558
  try {
94
559
  const result = await uploadImage(accessToken, absPath);
95
560
  uploadCache.set(rawPath, result.url);
@@ -98,9 +563,7 @@ export async function handleToolCall(name, args) {
98
563
  uploadErrors.push(`${rawPath}: ${error.message}`);
99
564
  }
100
565
  }
101
- for (const [localPath, cdnUrl] of uploadCache) {
102
- markdown = markdown.replaceAll(`](${localPath})`, `](${cdnUrl})`);
103
- }
566
+ markdown = replaceMarkdownImageSources(markdown, uploadCache);
104
567
  if (uploadErrors.length > 0) {
105
568
  return {
106
569
  content: [{ type: "text", text: `Image upload failed:\n${uploadErrors.join("\n")}` }],
@@ -108,10 +571,14 @@ export async function handleToolCall(name, args) {
108
571
  };
109
572
  }
110
573
  }
111
- const html = parseMarkdown(markdown, theme, title);
574
+ const html = parseMarkdown(markdown, theme, title, fontSizePreset);
112
575
  const savedPath = saveHtmlCache(html);
576
+ const visibleMetaLines = [`cacheHtmlPath=${savedPath}`];
113
577
  return {
114
- content: [{ type: "text", text: html }],
578
+ content: [
579
+ { type: "text", text: html },
580
+ { type: "text", text: visibleMetaLines.join("\n") }
581
+ ],
115
582
  meta: { cacheHtmlPath: savedPath }
116
583
  };
117
584
  }
@@ -178,11 +645,124 @@ export async function handleToolCall(name, args) {
178
645
  return { content: [{ type: "text", text: error.message }], isError: true };
179
646
  }
180
647
  }
648
+ if (name === "wechat_markdown_to_draft") {
649
+ const accessToken = args.access_token;
650
+ const rawArticleTitle = args.article_title;
651
+ const author = typeof args.author === "string" ? args.author : undefined;
652
+ const digest = typeof args.digest === "string" ? args.digest : undefined;
653
+ const contentSourceUrl = typeof args.content_source_url === "string" ? args.content_source_url : undefined;
654
+ const thumbMediaIdInput = typeof args.thumb_media_id === "string" ? args.thumb_media_id : undefined;
655
+ const needOpenComment = args.need_open_comment;
656
+ const onlyFansCanComment = args.only_fans_can_comment;
657
+ if (typeof accessToken !== "string" || !accessToken.trim()) {
658
+ return { content: [{ type: "text", text: "access_token is required." }], isError: true };
659
+ }
660
+ if (needOpenComment !== undefined && needOpenComment !== 0 && needOpenComment !== 1) {
661
+ return { content: [{ type: "text", text: "need_open_comment must be 0 or 1." }], isError: true };
662
+ }
663
+ if (onlyFansCanComment !== undefined && onlyFansCanComment !== 0 && onlyFansCanComment !== 1) {
664
+ return { content: [{ type: "text", text: "only_fans_can_comment must be 0 or 1." }], isError: true };
665
+ }
666
+ const source = await resolveMarkdownSource(args);
667
+ if (isToolResult(source)) {
668
+ return source;
669
+ }
670
+ const articleTitle = inferArticleTitle(rawArticleTitle, source.markdown, source.markdownPath);
671
+ if (!articleTitle) {
672
+ return {
673
+ content: [{ type: "text", text: "article_title is required when markdown has no leading H1 and markdown_path is unavailable." }],
674
+ isError: true
675
+ };
676
+ }
677
+ let markdownForConvert = source.markdown;
678
+ const markdownPath = source.markdownPath;
679
+ const baseDir = markdownPath ? dirname(markdownPath) : process.cwd();
680
+ const imageTokens = parseMarkdownImageTokens(markdownForConvert);
681
+ let designatedCoverMediaId;
682
+ let firstImageCoverMediaId;
683
+ if (!thumbMediaIdInput) {
684
+ const designatedCoverToken = imageTokens.find((token) => token.title?.trim() === "封面");
685
+ if (designatedCoverToken && !isRemoteUrl(designatedCoverToken.src)) {
686
+ const coverExt = getFileExtension(designatedCoverToken.src);
687
+ if (coverExt === "jpg" || coverExt === "jpeg") {
688
+ const coverPath = designatedCoverToken.src.startsWith("/") ? designatedCoverToken.src : resolve(baseDir, designatedCoverToken.src);
689
+ try {
690
+ const coverResult = await addMaterial(accessToken, "thumb", coverPath);
691
+ designatedCoverMediaId = coverResult.media_id;
692
+ markdownForConvert = removeMarkdownSlice(markdownForConvert, designatedCoverToken.start, designatedCoverToken.end);
693
+ }
694
+ catch (error) {
695
+ return { content: [{ type: "text", text: `Cover upload failed: ${error.message}` }], isError: true };
696
+ }
697
+ }
698
+ }
699
+ if (!designatedCoverMediaId) {
700
+ const firstLocalImage = imageTokens.find((token) => !isRemoteUrl(token.src));
701
+ if (firstLocalImage) {
702
+ const ext = getFileExtension(firstLocalImage.src);
703
+ if (ext === "jpg" || ext === "jpeg") {
704
+ const firstPath = firstLocalImage.src.startsWith("/") ? firstLocalImage.src : resolve(baseDir, firstLocalImage.src);
705
+ try {
706
+ const firstResult = await addMaterial(accessToken, "thumb", firstPath);
707
+ firstImageCoverMediaId = firstResult.media_id;
708
+ }
709
+ catch {
710
+ firstImageCoverMediaId = undefined;
711
+ }
712
+ }
713
+ }
714
+ }
715
+ }
716
+ markdownForConvert = removeLeadingAtxH1(markdownForConvert);
717
+ const convertResult = await handleToolCall("convert_markdown_to_wechat_html", {
718
+ markdown: markdownForConvert,
719
+ markdown_path: markdownPath,
720
+ theme: args.theme,
721
+ font_size_preset: args.font_size_preset,
722
+ access_token: accessToken
723
+ });
724
+ if (convertResult.isError) {
725
+ return convertResult;
726
+ }
727
+ const html = convertResult.content[0]?.text;
728
+ if (!validateMarkdown(html)) {
729
+ return {
730
+ content: [{ type: "text", text: "Failed to generate HTML content from markdown." }],
731
+ isError: true
732
+ };
733
+ }
734
+ const finalThumbMediaId = thumbMediaIdInput || designatedCoverMediaId || firstImageCoverMediaId;
735
+ const article = {
736
+ title: articleTitle,
737
+ content: html,
738
+ ...(author ? { author } : {}),
739
+ ...(digest ? { digest } : {}),
740
+ ...(contentSourceUrl ? { content_source_url: contentSourceUrl } : {}),
741
+ ...(finalThumbMediaId ? { thumb_media_id: finalThumbMediaId } : {}),
742
+ ...(needOpenComment === 0 || needOpenComment === 1 ? { need_open_comment: needOpenComment } : {}),
743
+ ...(onlyFansCanComment === 0 || onlyFansCanComment === 1 ? { only_fans_can_comment: onlyFansCanComment } : {})
744
+ };
745
+ try {
746
+ const draftResult = await draftAdd(accessToken, [article]);
747
+ return {
748
+ content: [{ type: "text", text: JSON.stringify(draftResult, null, 2) }],
749
+ meta: {
750
+ mediaId: draftResult.media_id,
751
+ cacheHtmlPath: convertResult.meta?.cacheHtmlPath,
752
+ ...(finalThumbMediaId ? { thumbMediaId: finalThumbMediaId } : {})
753
+ }
754
+ };
755
+ }
756
+ catch (error) {
757
+ return { content: [{ type: "text", text: error.message }], isError: true };
758
+ }
759
+ }
181
760
  if (name === "wechat_draft_update") {
182
761
  const accessToken = args.access_token;
183
762
  const mediaId = args.media_id;
184
763
  const index = args.index ?? 0;
185
764
  const article = args.article;
765
+ const baseDirArg = args.base_dir;
186
766
  if (typeof accessToken !== "string" || !accessToken.trim()) {
187
767
  return { content: [{ type: "text", text: "access_token is required." }], isError: true };
188
768
  }
@@ -195,8 +775,20 @@ export async function handleToolCall(name, args) {
195
775
  if (typeof article !== "object" || article === null) {
196
776
  return { content: [{ type: "text", text: "article is required." }], isError: true };
197
777
  }
778
+ const baseDir = typeof baseDirArg === "string" && baseDirArg.trim() ? baseDirArg.trim() : process.cwd();
779
+ const articleToUpdate = { ...article };
780
+ if (typeof articleToUpdate.content === "string" && articleToUpdate.content.trim()) {
781
+ try {
782
+ const sanitizedHtml = removeLeadingHtmlH1(articleToUpdate.content);
783
+ const replaced = await uploadLocalImageSourcesInHtml(sanitizedHtml, accessToken, baseDir);
784
+ articleToUpdate.content = replaced.html;
785
+ }
786
+ catch (error) {
787
+ return { content: [{ type: "text", text: error.message }], isError: true };
788
+ }
789
+ }
198
790
  try {
199
- const result = await draftUpdate(accessToken, mediaId, index, article);
791
+ const result = await draftUpdate(accessToken, mediaId, index, articleToUpdate);
200
792
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
201
793
  }
202
794
  catch (error) {
@@ -212,7 +804,7 @@ export async function handleToolCall(name, args) {
212
804
  if (typeof filePath !== "string" || !filePath.trim()) {
213
805
  return { content: [{ type: "text", text: "file_path is required." }], isError: true };
214
806
  }
215
- const ext = filePath.toLowerCase().split(".").pop();
807
+ const ext = getFileExtension(filePath);
216
808
  if (ext !== "jpg" && ext !== "jpeg" && ext !== "png") {
217
809
  return { content: [{ type: "text", text: "Only JPG and PNG files are supported." }], isError: true };
218
810
  }
@@ -224,6 +816,43 @@ export async function handleToolCall(name, args) {
224
816
  return { content: [{ type: "text", text: error.message }], isError: true };
225
817
  }
226
818
  }
819
+ if (name === "wechat_add_material") {
820
+ const accessToken = args.access_token;
821
+ const type = args.type;
822
+ const filePath = args.file_path;
823
+ const title = typeof args.title === "string" ? args.title.trim() : undefined;
824
+ const introduction = typeof args.introduction === "string" ? args.introduction.trim() : undefined;
825
+ if (typeof accessToken !== "string" || !accessToken.trim()) {
826
+ return { content: [{ type: "text", text: "access_token is required." }], isError: true };
827
+ }
828
+ if (typeof type !== "string" || !PERMANENT_MATERIAL_TYPES.includes(type)) {
829
+ return { content: [{ type: "text", text: "type must be one of: image, voice, video, thumb." }], isError: true };
830
+ }
831
+ if (typeof filePath !== "string" || !filePath.trim()) {
832
+ return { content: [{ type: "text", text: "file_path is required." }], isError: true };
833
+ }
834
+ const ext = getFileExtension(filePath);
835
+ const allowedExt = MATERIAL_EXTENSION_RULES[type];
836
+ if (!ext || !allowedExt.includes(ext)) {
837
+ return {
838
+ content: [{ type: "text", text: `Invalid file extension for type=${type}. Allowed: ${allowedExt.join(", ")}.` }],
839
+ isError: true
840
+ };
841
+ }
842
+ if (type === "video" && (!title || !introduction)) {
843
+ return {
844
+ content: [{ type: "text", text: "For video material, both title and introduction are required." }],
845
+ isError: true
846
+ };
847
+ }
848
+ try {
849
+ const result = await addMaterial(accessToken, type, filePath, title || introduction ? { title, introduction } : undefined);
850
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
851
+ }
852
+ catch (error) {
853
+ return { content: [{ type: "text", text: error.message }], isError: true };
854
+ }
855
+ }
227
856
  if (name === "wechat_draft_batchget") {
228
857
  const accessToken = args.access_token;
229
858
  const offset = args.offset ?? 0;