@zzclub/pipeline 0.4.0 → 0.5.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/README.md CHANGED
@@ -409,7 +409,7 @@ src/
409
409
  | `review` | Update content review status |
410
410
  | `abandon` | Mark one or more tasks as abandoned |
411
411
 
412
- `ops` 组,7 个命令:
412
+ `ops` 组,9 个命令:
413
413
 
414
414
  | 命令 | 说明 |
415
415
  | --- | --- |
@@ -420,6 +420,8 @@ src/
420
420
  | `config` | Read or update pipeline config |
421
421
  | `doctor` | Inspect resolved paths and provider health |
422
422
  | `hermes-metrics` | Show Hermes execution metrics per task |
423
+ | `wx-drafts` | List or get drafts from WeChat draft box |
424
+ | `wx-draft-delete` | Delete a draft from WeChat draft box |
423
425
 
424
426
  ## 常用命令
425
427
 
@@ -553,6 +555,31 @@ bun run src/cli.ts wechat-export --body /abs/path/body.md --account default
553
555
  bun run src/cli.ts cos-upload --file /abs/path/image.png --folder notes/note-id --alt image
554
556
  ```
555
557
 
558
+ ### 草稿箱管理
559
+
560
+ ```bash
561
+ # 列出草稿(账号不传时使用 wx.defaultAccount)
562
+ bun run src/cli.ts wx-drafts --limit 10
563
+
564
+ # 获取某篇草稿的完整内容(含 HTML)
565
+ bun run src/cli.ts wx-drafts --media-id MEDIA_ID
566
+
567
+ # 删除一篇草稿
568
+ bun run src/cli.ts wx-draft-delete --media-id MEDIA_ID
569
+ ```
570
+
571
+ `--account` 参数可选,默认使用配置中的 `wx.defaultAccount`。
572
+
573
+ ### init 可选参数
574
+
575
+ ```bash
576
+ # 更新已有草稿而非新建(配合 --existing-draft-media-id)
577
+ bun run src/cli.ts init ... --existing-draft-media-id MEDIA_ID
578
+
579
+ # 关联 Nezus note 用于发布结果回调
580
+ bun run src/cli.ts init ... --note-id NOTE_ID
581
+ ```
582
+
556
583
  ## 状态文件
557
584
 
558
585
  最重要的字段是:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -16,7 +16,8 @@
16
16
  * [--requires-research] \
17
17
  * [--requires-style] \
18
18
  * [--requires-render] \
19
- * [--requires-publish]
19
+ * [--requires-publish] \
20
+ * [--existing-draft-media-id MEDIA_ID]
20
21
  *
21
22
  * Output: JSON state written to {workspace}/.zzhub-media/runs/{run_id}.json
22
23
  * Prints the state path to stdout.
@@ -60,6 +61,7 @@ Options:
60
61
  --requires-style Flag
61
62
  --requires-render Flag
62
63
  --requires-publish Flag
64
+ --existing-draft-media-id Update existing WeChat draft instead of creating new one (optional)
63
65
  `.trim());
64
66
  return;
65
67
  }
@@ -72,6 +74,8 @@ Options:
72
74
  const accountOverride = optionalArg(parsed, "account");
73
75
  const styleHint = optionalArg(parsed, "style-hint") ?? null;
74
76
  const newspicRenderSpecFile = optionalArg(parsed, "newspic-render-spec-file");
77
+ const existingDraftMediaId = optionalArg(parsed, "existing-draft-media-id") ?? null;
78
+ const noteId = optionalArg(parsed, "note-id") ?? null;
75
79
  const config = loadConfig();
76
80
  const workspace = resolveWorkspaceRoot(optionalArg(parsed, "workspace"), config);
77
81
 
@@ -112,6 +116,8 @@ Options:
112
116
  render: flagArg(parsed, "requires-render"),
113
117
  publish: flagArg(parsed, "requires-publish"),
114
118
  },
119
+ existing_draft_media_id: existingDraftMediaId,
120
+ note_id: noteId,
115
121
  };
116
122
 
117
123
  await writeState(statePath, state);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * wx-draft-delete — Delete a draft from WeChat draft box.
3
+ *
4
+ * Usage:
5
+ * zzhub-pipeline wx-draft-delete [--account <account>] --media-id <media_id>
6
+ */
7
+
8
+ import { optionalArg, parseArgs, requireArg } from "../args";
9
+ import { loadConfig } from "../config";
10
+ import { printHelp, printResult } from "../output";
11
+ import { deleteWxDraft } from "../providers/wechat";
12
+
13
+ export async function wxDraftDelete(args: string[]): Promise<void> {
14
+ const parsed = parseArgs(args);
15
+
16
+ if (parsed.help) {
17
+ printHelp(`
18
+ Usage: zzhub-pipeline wx-draft-delete [options]
19
+
20
+ Options:
21
+ --account WeChat account name (defaults to wx.defaultAccount from config)
22
+ --media-id Draft media_id to delete (required)
23
+
24
+ Examples:
25
+ zzhub-pipeline wx-draft-delete --media-id MEDIA_ID
26
+ `.trim());
27
+ return;
28
+ }
29
+
30
+ const config = loadConfig();
31
+ const account = optionalArg(parsed, "account") || config.wx.defaultAccount;
32
+ const mediaId = requireArg(parsed, "media-id", "draft media_id to delete");
33
+
34
+ const result = await deleteWxDraft({ account, mediaId, config });
35
+ printResult(result);
36
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * wx-drafts — List or get WeChat draft box drafts.
3
+ *
4
+ * Usage:
5
+ * zzhub-pipeline wx-drafts [--account <account>] [--limit 20] [--offset 0]
6
+ * zzhub-pipeline wx-drafts [--account <account>] --media-id <media_id>
7
+ */
8
+
9
+ import { optionalArg, parseArgs } from "../args";
10
+ import { loadConfig } from "../config";
11
+ import { printHelp, printResult } from "../output";
12
+ import { getWxDraft, getWxDraftList } from "../providers/wechat";
13
+
14
+ export async function wxDrafts(args: string[]): Promise<void> {
15
+ const parsed = parseArgs(args);
16
+
17
+ if (parsed.help) {
18
+ printHelp(`
19
+ Usage: zzhub-pipeline wx-drafts [options]
20
+
21
+ Options:
22
+ --account WeChat account name (defaults to wx.defaultAccount from config)
23
+ --media-id Draft media_id to fetch a single draft (optional)
24
+ --limit Number of drafts to list, default 20, max 20 (optional)
25
+ --offset Pagination offset, default 0 (optional)
26
+
27
+ Examples:
28
+ zzhub-pipeline wx-drafts --limit 10
29
+ zzhub-pipeline wx-drafts --media-id MEDIA_ID
30
+ `.trim());
31
+ return;
32
+ }
33
+
34
+ const config = loadConfig();
35
+ const account = optionalArg(parsed, "account") || config.wx.defaultAccount;
36
+ const mediaId = optionalArg(parsed, "media-id") ?? null;
37
+ const limit = optionalArg(parsed, "limit") ? Number.parseInt(optionalArg(parsed, "limit")!, 10) : undefined;
38
+ const offset = optionalArg(parsed, "offset") ? Number.parseInt(optionalArg(parsed, "offset")!, 10) : undefined;
39
+
40
+ if (mediaId) {
41
+ const result = await getWxDraft({ account, mediaId, config });
42
+ printResult(result);
43
+ } else {
44
+ const result = await getWxDraftList({ account, limit, offset, config });
45
+ printResult(result);
46
+ }
47
+ }
package/src/plugins.ts CHANGED
@@ -22,6 +22,8 @@ import { status } from "./commands/status";
22
22
  import { syncBlog } from "./commands/sync-blog";
23
23
  import { tasks } from "./commands/tasks";
24
24
  import { wechatExport } from "./commands/wechat-export";
25
+ import { wxDrafts } from "./commands/wx-drafts";
26
+ import { wxDraftDelete } from "./commands/wx-draft-delete";
25
27
 
26
28
  export interface CommandDefinition {
27
29
  name: string;
@@ -69,6 +71,8 @@ export function getCommandPlugins(): CommandPlugin[] {
69
71
  { name: "config", summary: "Read or update pipeline config", plugin: "ops", handler: configCommand },
70
72
  { name: "doctor", summary: "Inspect resolved paths and provider health", plugin: "ops", handler: doctor },
71
73
  { name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
74
+ { name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
75
+ { name: "wx-draft-delete", summary: "Delete a draft from WeChat draft box", plugin: "ops", handler: wxDraftDelete },
72
76
  ],
73
77
  },
74
78
  ];
@@ -80,6 +80,10 @@ async function publishWechatArticleRoute({
80
80
  html,
81
81
  photos,
82
82
  config,
83
+ existingDraftMediaId: state.intent.existing_draft_media_id,
84
+ noteId: state.intent.note_id,
85
+ nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
86
+ nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[state.route.account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
83
87
  });
84
88
  } catch (error) {
85
89
  return {
@@ -145,6 +149,10 @@ async function publishWechatNewspicRoute({
145
149
  content: cleanContent,
146
150
  photos,
147
151
  config,
152
+ existingDraftMediaId: state.intent.existing_draft_media_id,
153
+ noteId: state.intent.note_id,
154
+ nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
155
+ nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[state.route.account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
148
156
  });
149
157
  } catch (error) {
150
158
  return {
@@ -4,7 +4,11 @@ import { PipelineConfig, WxAccountConfig } from "../config";
4
4
 
5
5
  const TOKEN_PATH = "/api/v1/wx/cgi-bin/token";
6
6
  const MATERIAL_PATH = "/api/v1/wx/cgi-bin/material/add_material";
7
- const DRAFT_PATH = "/api/v1/wx/cgi-bin/draft/add";
7
+ const DRAFT_ADD_PATH = "/api/v1/wx/cgi-bin/draft/add";
8
+ const DRAFT_UPDATE_PATH = "/api/v1/wx/cgi-bin/draft/update";
9
+ const DRAFT_GET_PATH = "/api/v1/wx/cgi-bin/draft/get";
10
+ const DRAFT_DELETE_PATH = "/api/v1/wx/cgi-bin/draft/delete";
11
+ const BATCH_GET_PATH = "/api/v1/wx/cgi-bin/draft/batchget";
8
12
  const TOKEN_TIMEOUT = 10000;
9
13
  const UPLOAD_TIMEOUT = 60000;
10
14
  const DRAFT_TIMEOUT = 30000;
@@ -78,14 +82,19 @@ interface BaseWechatPublishInput {
78
82
  photos?: string[];
79
83
  timeout?: number;
80
84
  config: PipelineConfig;
85
+ noteId?: string | null;
86
+ nezusBaseUrl?: string | null;
87
+ nezusPat?: string | null;
81
88
  }
82
89
 
83
90
  export interface WechatDraftInput extends BaseWechatPublishInput {
84
91
  html: string;
92
+ existingDraftMediaId?: string | null;
85
93
  }
86
94
 
87
95
  export interface WechatNewspicInput extends BaseWechatPublishInput {
88
96
  content: string;
97
+ existingDraftMediaId?: string | null;
89
98
  }
90
99
 
91
100
  function requireValue(name: string, value: string | undefined): string {
@@ -459,15 +468,21 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
459
468
  const uploadResult = await uploadPhotos(runtime, accessToken, finalPhotos, uploadTimeout);
460
469
  const replacedHtml = replaceImageUrls(input.html, uploadResult.imageUrlMap);
461
470
 
462
- const response = await requestJson(
463
- `${runtime.baseUrl}${DRAFT_PATH}`,
464
- {
465
- method: "POST",
466
- headers: {
467
- "Content-Type": "application/json",
468
- Authorization: `Bearer ${runtime.pat}`,
469
- },
470
- body: {
471
+ const isUpdate = !!input.existingDraftMediaId;
472
+ const draftPath = isUpdate ? DRAFT_UPDATE_PATH : DRAFT_ADD_PATH;
473
+ const draftBody = isUpdate
474
+ ? {
475
+ access_token: accessToken,
476
+ media_id: input.existingDraftMediaId,
477
+ index: 0,
478
+ articles: {
479
+ article_type: "news",
480
+ title: input.title,
481
+ content: replacedHtml,
482
+ thumb_media_id: uploadResult.coverMediaId,
483
+ },
484
+ }
485
+ : {
471
486
  access_token: accessToken,
472
487
  articles: [
473
488
  {
@@ -477,11 +492,34 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
477
492
  thumb_media_id: uploadResult.coverMediaId,
478
493
  },
479
494
  ],
495
+ };
496
+
497
+ const response = await requestJson(
498
+ `${runtime.baseUrl}${draftPath}`,
499
+ {
500
+ method: "POST",
501
+ headers: {
502
+ "Content-Type": "application/json",
503
+ Authorization: `Bearer ${runtime.pat}`,
480
504
  },
505
+ body: draftBody,
481
506
  },
482
507
  draftTimeout,
483
508
  );
484
509
 
510
+ const draftMediaId = isUpdate ? input.existingDraftMediaId : (response as any)?.data?.media_id || (response as any)?.media_id || null;
511
+
512
+ // ── Callback to Nezus to persist media_id ──
513
+ if (draftMediaId && input.noteId && input.nezusBaseUrl && input.nezusPat) {
514
+ await notifyNezusPublishResult({
515
+ nezusBaseUrl: input.nezusBaseUrl,
516
+ nezusPat: input.nezusPat,
517
+ noteId: input.noteId,
518
+ wechatMediaId: draftMediaId,
519
+ publishType: "article",
520
+ });
521
+ }
522
+
485
523
  return {
486
524
  account: runtime.accountName,
487
525
  articleType: "news",
@@ -489,6 +527,8 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
489
527
  photosCount: finalPhotos.length,
490
528
  totalUploaded: uploadResult.totalUploaded,
491
529
  coverMediaId: uploadResult.coverMediaId,
530
+ isUpdate,
531
+ draftMediaId,
492
532
  response,
493
533
  };
494
534
  }
@@ -512,15 +552,22 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
512
552
  image_list: uploadResult.uploadedMedia.map((item) => ({ image_media_id: item.mediaId })),
513
553
  };
514
554
 
515
- const response = await requestJson(
516
- `${runtime.baseUrl}${DRAFT_PATH}`,
517
- {
518
- method: "POST",
519
- headers: {
520
- "Content-Type": "application/json",
521
- Authorization: `Bearer ${runtime.pat}`,
522
- },
523
- body: {
555
+ const isUpdate = !!input.existingDraftMediaId;
556
+ const draftPath = isUpdate ? DRAFT_UPDATE_PATH : DRAFT_ADD_PATH;
557
+ const draftBody = isUpdate
558
+ ? {
559
+ access_token: accessToken,
560
+ media_id: input.existingDraftMediaId,
561
+ index: 0,
562
+ articles: {
563
+ article_type: "newspic",
564
+ title: input.title,
565
+ content: input.content,
566
+ thumb_media_id: uploadResult.coverMediaId,
567
+ image_info: imageInfo,
568
+ },
569
+ }
570
+ : {
524
571
  access_token: accessToken,
525
572
  articles: [
526
573
  {
@@ -531,11 +578,34 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
531
578
  image_info: imageInfo,
532
579
  },
533
580
  ],
581
+ };
582
+
583
+ const response = await requestJson(
584
+ `${runtime.baseUrl}${draftPath}`,
585
+ {
586
+ method: "POST",
587
+ headers: {
588
+ "Content-Type": "application/json",
589
+ Authorization: `Bearer ${runtime.pat}`,
534
590
  },
591
+ body: draftBody,
535
592
  },
536
593
  draftTimeout,
537
594
  );
538
595
 
596
+ const draftMediaId = isUpdate ? input.existingDraftMediaId : (response as any)?.data?.media_id || (response as any)?.media_id || null;
597
+
598
+ // ── Callback to Nezus to persist media_id ──
599
+ if (draftMediaId && input.noteId && input.nezusBaseUrl && input.nezusPat) {
600
+ await notifyNezusPublishResult({
601
+ nezusBaseUrl: input.nezusBaseUrl,
602
+ nezusPat: input.nezusPat,
603
+ noteId: input.noteId,
604
+ wechatMediaId: draftMediaId,
605
+ publishType: "newspic",
606
+ });
607
+ }
608
+
539
609
  return {
540
610
  account: runtime.accountName,
541
611
  articleType: "newspic",
@@ -543,6 +613,146 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
543
613
  photosCount: finalPhotos.length,
544
614
  totalUploaded: uploadResult.totalUploaded,
545
615
  coverMediaId: uploadResult.coverMediaId,
616
+ isUpdate,
617
+ draftMediaId,
618
+ response,
619
+ };
620
+ }
621
+
622
+ // ── Nezus callback ────────────────────────────────────────────────
623
+
624
+ async function notifyNezusPublishResult(params: {
625
+ nezusBaseUrl: string;
626
+ nezusPat: string;
627
+ noteId: string;
628
+ wechatMediaId: string;
629
+ publishType: string;
630
+ }): Promise<void> {
631
+ try {
632
+ await requestJson(
633
+ `${params.nezusBaseUrl}/api/v1/notes/${params.noteId}/publish-result`,
634
+ {
635
+ method: "POST",
636
+ headers: {
637
+ "Content-Type": "application/json",
638
+ Authorization: `Bearer ${params.nezusPat}`,
639
+ },
640
+ body: {
641
+ wechatMediaId: params.wechatMediaId,
642
+ publishType: params.publishType,
643
+ },
644
+ },
645
+ 10000,
646
+ );
647
+ console.error(`[nezus] publish-result callback OK for note ${params.noteId}`);
648
+ } catch (err) {
649
+ // Non-fatal: the draft was created successfully even if the callback failed
650
+ console.error(`[nezus] publish-result callback failed for note ${params.noteId}:`, err);
651
+ }
652
+ }
653
+
654
+ // ── Draft management ──────────────────────────────────────────────
655
+
656
+ export interface WxDraftListInput {
657
+ account?: string;
658
+ limit?: number;
659
+ offset?: number;
660
+ config: PipelineConfig;
661
+ }
662
+
663
+ export interface WxDraftGetInput {
664
+ account?: string;
665
+ mediaId: string;
666
+ config: PipelineConfig;
667
+ }
668
+
669
+ export interface WxDraftDeleteInput {
670
+ account?: string;
671
+ mediaId: string;
672
+ config: PipelineConfig;
673
+ }
674
+
675
+ export async function getWxDraftList(input: WxDraftListInput): Promise<Record<string, unknown>> {
676
+ const runtime = getWxRuntimeConfig(input.config, input.account);
677
+ const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
678
+ const accessToken = await fetchAccessToken(runtime, tokenTimeout);
679
+
680
+ const response = await requestJson(
681
+ `${runtime.baseUrl}${BATCH_GET_PATH}`,
682
+ {
683
+ method: "POST",
684
+ headers: {
685
+ "Content-Type": "application/json",
686
+ Authorization: `Bearer ${runtime.pat}`,
687
+ },
688
+ body: {
689
+ access_token: accessToken,
690
+ offset: input.offset ?? 0,
691
+ count: input.limit ?? 20,
692
+ no_content: true,
693
+ },
694
+ },
695
+ resolveTimeout(30000, runtime.timeout),
696
+ );
697
+
698
+ return {
699
+ account: runtime.accountName,
700
+ response,
701
+ };
702
+ }
703
+
704
+ export async function getWxDraft(input: WxDraftGetInput): Promise<Record<string, unknown>> {
705
+ const runtime = getWxRuntimeConfig(input.config, input.account);
706
+ const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
707
+ const accessToken = await fetchAccessToken(runtime, tokenTimeout);
708
+
709
+ const response = await requestJson(
710
+ `${runtime.baseUrl}${DRAFT_GET_PATH}`,
711
+ {
712
+ method: "POST",
713
+ headers: {
714
+ "Content-Type": "application/json",
715
+ Authorization: `Bearer ${runtime.pat}`,
716
+ },
717
+ body: {
718
+ access_token: accessToken,
719
+ media_id: input.mediaId,
720
+ },
721
+ },
722
+ resolveTimeout(30000, runtime.timeout),
723
+ );
724
+
725
+ return {
726
+ account: runtime.accountName,
727
+ mediaId: input.mediaId,
728
+ response,
729
+ };
730
+ }
731
+
732
+ export async function deleteWxDraft(input: WxDraftDeleteInput): Promise<Record<string, unknown>> {
733
+ const runtime = getWxRuntimeConfig(input.config, input.account);
734
+ const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout);
735
+ const accessToken = await fetchAccessToken(runtime, tokenTimeout);
736
+
737
+ const response = await requestJson(
738
+ `${runtime.baseUrl}${DRAFT_DELETE_PATH}`,
739
+ {
740
+ method: "POST",
741
+ headers: {
742
+ "Content-Type": "application/json",
743
+ Authorization: `Bearer ${runtime.pat}`,
744
+ },
745
+ body: {
746
+ access_token: accessToken,
747
+ media_id: input.mediaId,
748
+ },
749
+ },
750
+ resolveTimeout(30000, runtime.timeout),
751
+ );
752
+
753
+ return {
754
+ account: runtime.accountName,
755
+ mediaId: input.mediaId,
546
756
  response,
547
757
  };
548
758
  }
@@ -303,6 +303,8 @@ const IntentSchema = withObjectDefault(
303
303
  style_hint: z.string().nullable().default(null),
304
304
  newspic_render: NewspicRenderSpecSchema.nullable().default(null),
305
305
  requires: IntentRequiresSchema,
306
+ existing_draft_media_id: z.string().nullable().default(null),
307
+ note_id: z.string().nullable().default(null),
306
308
  }),
307
309
  );
308
310