dingtalk-pingo 0.1.0 → 0.2.0

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/pingo.js CHANGED
@@ -7421,10 +7421,22 @@ var FORMAT_REJECTION_CODES = /* @__PURE__ */ new Set([
7421
7421
  var PingoError = class extends Error {
7422
7422
  category;
7423
7423
  code;
7424
- constructor(category, message, code) {
7424
+ /** 为 true 时,发送管线可以降级到下一种消息格式重试(目前仅用于卡片被钉钉明确拒绝)。 */
7425
+ degradable;
7426
+ constructor(category, message, code, options) {
7425
7427
  super(message);
7426
7428
  this.category = category;
7427
7429
  this.code = code;
7430
+ this.degradable = options?.degradable === true;
7431
+ }
7432
+ };
7433
+ var PartialSendError = class extends PingoError {
7434
+ sent;
7435
+ failed;
7436
+ constructor(cause, sent, failed) {
7437
+ super(cause.category, cause.message, cause.code);
7438
+ this.sent = sent;
7439
+ this.failed = failed;
7428
7440
  }
7429
7441
  };
7430
7442
  function classifyDingTalkError(httpStatus, code) {
@@ -7443,6 +7455,7 @@ function isFormatRejection(code) {
7443
7455
  var CRED_KEYS = ["clientId", "clientSecret", "robotCode", "userId"];
7444
7456
  var MESSAGE_TYPES = ["success", "warning", "error", "info"];
7445
7457
  var FORMATS = ["text", "markdown"];
7458
+ var SEND_FORMATS = ["card", "markdown", "text"];
7446
7459
  function fail(message) {
7447
7460
  throw new PingoError("config", message);
7448
7461
  }
@@ -7509,15 +7522,36 @@ function validateConfig(raw, opts) {
7509
7522
  const device = { id, name: str(deviceRaw.name) ?? id };
7510
7523
  const icon = str(deviceRaw.icon);
7511
7524
  if (icon !== void 0) device.icon = icon;
7525
+ const avatar = str(deviceRaw.avatar);
7526
+ if (avatar !== void 0) {
7527
+ if (!/^https?:\/\//i.test(avatar)) {
7528
+ fail(`\u914D\u7F6E\u9879 device.avatar \u5FC5\u987B\u662F http(s) \u56FE\u7247\u5730\u5740,\u5F53\u524D\u503C:${avatar}\u3002`);
7529
+ }
7530
+ device.avatar = avatar;
7531
+ }
7532
+ const cardRaw = isRecord(raw.card) ? raw.card : {};
7533
+ const card = {};
7534
+ const templateId = str(cardRaw.templateId);
7535
+ if (templateId !== void 0) card.templateId = templateId;
7536
+ const preferredFormat = pickEnum(
7537
+ msgRaw.preferredFormat,
7538
+ SEND_FORMATS,
7539
+ card.templateId !== void 0 ? "card" : "markdown",
7540
+ "message.preferredFormat"
7541
+ );
7542
+ if (preferredFormat === "card" && card.templateId === void 0) {
7543
+ fail("message.preferredFormat \u4E3A card \u65F6\u5FC5\u987B\u914D\u7F6E card.templateId(\u9489\u9489\u5361\u7247\u6A21\u677F ID)\u3002");
7544
+ }
7512
7545
  return {
7513
7546
  device,
7514
7547
  dingtalk,
7515
7548
  message: {
7516
7549
  defaultType: pickEnum(msgRaw.defaultType, MESSAGE_TYPES, "info", "message.defaultType"),
7517
- preferredFormat: pickEnum(msgRaw.preferredFormat, FORMATS, "markdown", "message.preferredFormat"),
7550
+ preferredFormat,
7518
7551
  fallbackFormat: pickEnum(msgRaw.fallbackFormat, FORMATS, "text", "message.fallbackFormat"),
7519
7552
  timeoutMs
7520
7553
  },
7554
+ card,
7521
7555
  templates
7522
7556
  };
7523
7557
  }
@@ -7616,10 +7650,14 @@ function loadConfig(opts) {
7616
7650
  const deviceId = nonEmpty(opts.env.PINGO_DEVICE_ID) ?? nonEmpty(deviceYaml.id) ?? opts.hostname;
7617
7651
  const deviceName = nonEmpty(opts.env.PINGO_DEVICE_NAME) ?? nonEmpty(deviceYaml.name) ?? opts.hostname;
7618
7652
  const deviceIcon = nonEmpty(opts.env.PINGO_DEVICE_ICON) ?? nonEmpty(deviceYaml.icon);
7653
+ const deviceAvatar = nonEmpty(opts.env.PINGO_DEVICE_AVATAR) ?? nonEmpty(deviceYaml.avatar);
7654
+ const cardYaml = isRecord2(yamlRaw.card) ? yamlRaw.card : {};
7655
+ const templateId = nonEmpty(opts.env.PINGO_CARD_TEMPLATE_ID) ?? nonEmpty(cardYaml.templateId);
7619
7656
  const merged = {
7620
- device: { id: deviceId, name: deviceName, icon: deviceIcon },
7657
+ device: { id: deviceId, name: deviceName, icon: deviceIcon, avatar: deviceAvatar },
7621
7658
  dingtalk: creds,
7622
7659
  message: yamlRaw.message,
7660
+ card: { templateId },
7623
7661
  templates: yamlRaw.templates
7624
7662
  };
7625
7663
  const config = validateConfig(merged, { requireCredentials: opts.requireCredentials });
@@ -7680,6 +7718,12 @@ var TYPE_ICONS = {
7680
7718
  error: "\u274C",
7681
7719
  info: "\u2139\uFE0F"
7682
7720
  };
7721
+ var TYPE_LABELS = {
7722
+ success: "\u5DF2\u5B8C\u6210",
7723
+ warning: "\u9700\u8981\u5173\u6CE8",
7724
+ error: "\u4EFB\u52A1\u5F02\u5E38",
7725
+ info: "\u6D88\u606F\u62A5\u544A"
7726
+ };
7683
7727
  var ACTION_TEXT = "\u26A0\uFE0F \u9700\u8981\u4EBA\u5DE5\u5904\u7406";
7684
7728
  function nonBlank(value) {
7685
7729
  if (value === void 0) return void 0;
@@ -7729,7 +7773,14 @@ function buildMessage(input, ctx) {
7729
7773
  let bodyTemplate = override.body ?? defaults.body;
7730
7774
  if (!isMarkdown) bodyTemplate = bodyTemplate.replaceAll("**", "");
7731
7775
  const title = collapseSpaces(renderTemplate(titleTemplate, titleVars));
7732
- const body = renderTemplate(bodyTemplate, bodyVars);
7776
+ let body = renderTemplate(bodyTemplate, bodyVars);
7777
+ const links = input.links ?? [];
7778
+ if (links.length > 0 && body !== "") {
7779
+ const linkLines = isMarkdown ? [links.map((l) => `[${escapeMarkdown(l.text)}](${l.url})`).join(" \xB7 ")] : links.map((l) => `${l.text}:${l.url}`);
7780
+ body = `${body}
7781
+
7782
+ ${linkLines.join("\n")}`;
7783
+ }
7733
7784
  if (title === "" || body === "") {
7734
7785
  throw new PingoError("content", "\u6E32\u67D3\u540E\u7684\u6807\u9898\u6216\u6B63\u6587\u4E3A\u7A7A,\u8BF7\u68C0\u67E5\u6D88\u606F\u5185\u5BB9\u4E0E\u6A21\u677F\u3002");
7735
7786
  }
@@ -7755,6 +7806,395 @@ ${body}`,
7755
7806
  };
7756
7807
  }
7757
7808
 
7809
+ // src/messages/card.ts
7810
+ import { randomUUID } from "node:crypto";
7811
+ function nonBlank2(value) {
7812
+ if (value === void 0) return void 0;
7813
+ const t = value.trim();
7814
+ return t === "" ? void 0 : t;
7815
+ }
7816
+ function senderName(device, agent, project) {
7817
+ return [device.name, nonBlank2(agent), nonBlank2(project)].filter((s) => s !== void 0).join(" \xB7 ");
7818
+ }
7819
+ function titleOf(type, title) {
7820
+ return `${TYPE_ICONS[type]} ${nonBlank2(title) ?? TYPE_LABELS[type]}`;
7821
+ }
7822
+ function btnsOf(links) {
7823
+ return JSON.stringify(
7824
+ links.map((l) => ({
7825
+ text: l.text,
7826
+ color: "blue",
7827
+ status: "normal",
7828
+ event: { type: "openLink", params: { url: l.url } }
7829
+ }))
7830
+ );
7831
+ }
7832
+ var CHART_TYPES = ["lineChart", "histogram", "pieChart"];
7833
+ function parseChart(raw) {
7834
+ let value;
7835
+ try {
7836
+ value = JSON.parse(raw);
7837
+ } catch (err) {
7838
+ throw new PingoError("usage", `--chart \u6587\u4EF6\u4E0D\u662F\u5408\u6CD5 JSON:${err.message}`);
7839
+ }
7840
+ const chart = value;
7841
+ if (chart === null || typeof chart !== "object" || Array.isArray(chart)) {
7842
+ throw new PingoError("usage", '--chart \u5FC5\u987B\u662F JSON \u5BF9\u8C61:{"type":"lineChart","data":[...],"config":{}}\u3002');
7843
+ }
7844
+ if (typeof chart.type !== "string" || !CHART_TYPES.includes(chart.type)) {
7845
+ throw new PingoError("usage", `--chart \u7684 type \u5FC5\u987B\u662F ${CHART_TYPES.join(" / ")} \u4E4B\u4E00\u3002`);
7846
+ }
7847
+ if (!Array.isArray(chart.data) || chart.data.length === 0) {
7848
+ throw new PingoError("usage", "--chart \u7684 data \u5FC5\u987B\u662F\u975E\u7A7A\u6570\u7EC4\u3002");
7849
+ }
7850
+ return JSON.stringify({ ...chart, config: chart.config ?? {} });
7851
+ }
7852
+ function contentOf(p, mediaIds) {
7853
+ const body = p.markdown !== void 0 && p.markdown.trim() !== "" ? p.markdown : nonBlank2(p.message);
7854
+ if (body === void 0) {
7855
+ throw new PingoError("content", "\u6D88\u606F\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A:\u8BF7\u63D0\u4F9B --message \u6216 --markdown\u3002");
7856
+ }
7857
+ const parts = [];
7858
+ if (p.actionRequired) parts.push(ACTION_TEXT);
7859
+ const task = nonBlank2(p.task);
7860
+ if (task !== void 0) parts.push(`\u4EFB\u52A1:${task}`);
7861
+ parts.push(body);
7862
+ if (mediaIds.length > 0) parts.push(mediaIds.map((id) => `![image](${id})`).join("\n"));
7863
+ const content = parts.join("\n\n");
7864
+ const bytes = Buffer.byteLength(content, "utf8");
7865
+ if (bytes > MAX_MSGPARAM_BYTES) {
7866
+ throw new PingoError(
7867
+ "content",
7868
+ `\u6D88\u606F\u8FC7\u957F:${bytes} \u5B57\u8282,\u8D85\u8FC7\u4E0A\u9650 ${MAX_MSGPARAM_BYTES} \u5B57\u8282,\u8BF7\u7CBE\u7B80\u5185\u5BB9\u540E\u91CD\u8BD5\u3002`
7869
+ );
7870
+ }
7871
+ return content;
7872
+ }
7873
+ function renderCard(input, ctx, mediaIds) {
7874
+ const content = contentOf(input, mediaIds);
7875
+ const cardParamMap = {
7876
+ senderName: senderName(ctx.device, input.agent, input.project),
7877
+ title: titleOf(input.type, input.title),
7878
+ content,
7879
+ btns: btnsOf(input.links ?? [])
7880
+ };
7881
+ if (ctx.device.avatar !== void 0) cardParamMap.avatar = ctx.device.avatar;
7882
+ const date = nonBlank2(input.date);
7883
+ if (date !== void 0) cardParamMap.date = date;
7884
+ if (input.chart !== void 0) cardParamMap.chart = input.chart;
7885
+ const preview = [cardParamMap.senderName, cardParamMap.title, "", content].join("\n");
7886
+ return { cardParamMap, preview, bytes: Buffer.byteLength(content, "utf8") };
7887
+ }
7888
+ function renderCardUpdate(input, ctx, mediaIds) {
7889
+ const map = {};
7890
+ if (input.type !== void 0 || input.title !== void 0) {
7891
+ map.title = titleOf(input.type ?? input.defaultType, input.title);
7892
+ }
7893
+ if (input.message !== void 0 || input.markdown !== void 0 || input.actionRequired === true || mediaIds.length > 0) {
7894
+ map.content = contentOf(input, mediaIds);
7895
+ }
7896
+ if (input.agent !== void 0 || input.project !== void 0) {
7897
+ map.senderName = senderName(ctx.device, input.agent, input.project);
7898
+ }
7899
+ if (input.links !== void 0) {
7900
+ map.btns = btnsOf(input.links);
7901
+ }
7902
+ const date = nonBlank2(input.date);
7903
+ if (date !== void 0) map.date = date;
7904
+ if (input.chart !== void 0) map.chart = input.chart;
7905
+ if (Object.keys(map).length === 0) {
7906
+ throw new PingoError(
7907
+ "usage",
7908
+ "pingo update \u81F3\u5C11\u9700\u8981\u4E00\u4E2A\u8981\u66F4\u65B0\u7684\u5B57\u6BB5(--type\u3001--title\u3001--message\u3001--markdown\u3001--image\u3001--link\u3001--agent\u3001--project\u3001--date\u3001--chart)\u3002"
7909
+ );
7910
+ }
7911
+ return map;
7912
+ }
7913
+ var CARD_ID = /^pingo-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
7914
+ function newCardId() {
7915
+ return `pingo-${randomUUID()}`;
7916
+ }
7917
+ function isCardId(value) {
7918
+ return CARD_ID.test(value);
7919
+ }
7920
+
7921
+ // src/messages/links.ts
7922
+ var MAX_LINKS = 3;
7923
+ function parseLinks(values) {
7924
+ if (values.length > MAX_LINKS) {
7925
+ throw new PingoError("usage", `--link \u6700\u591A ${MAX_LINKS} \u4E2A,\u5F53\u524D ${values.length} \u4E2A\u3002`);
7926
+ }
7927
+ return values.map((raw) => {
7928
+ const idx = raw.indexOf("=");
7929
+ const text = idx < 0 ? "" : raw.slice(0, idx).trim();
7930
+ const url = idx < 0 ? "" : raw.slice(idx + 1).trim();
7931
+ if (idx < 0 || text === "" || url === "") {
7932
+ throw new PingoError("usage", `--link \u683C\u5F0F\u5E94\u4E3A "\u6587\u5B57=URL":${raw}`);
7933
+ }
7934
+ if (!/^https?:\/\//i.test(url)) {
7935
+ throw new PingoError("usage", `--link \u53EA\u652F\u6301 http(s) \u5730\u5740:${url}`);
7936
+ }
7937
+ return { text, url };
7938
+ });
7939
+ }
7940
+
7941
+ // src/media/cover.ts
7942
+ import { spawn } from "node:child_process";
7943
+ import { deflateSync } from "node:zlib";
7944
+ var FFMPEG_TIMEOUT_MS = 1e4;
7945
+ var ffmpegFrameGrabber = (videoPath, atSec) => new Promise((resolve2) => {
7946
+ let settled = false;
7947
+ const done = (v) => {
7948
+ if (!settled) {
7949
+ settled = true;
7950
+ resolve2(v);
7951
+ }
7952
+ };
7953
+ let child;
7954
+ try {
7955
+ child = spawn(
7956
+ "ffmpeg",
7957
+ ["-loglevel", "error", "-ss", String(atSec), "-i", videoPath, "-frames:v", "1", "-f", "image2", "-vcodec", "mjpeg", "pipe:1"],
7958
+ { stdio: ["ignore", "pipe", "ignore"] }
7959
+ );
7960
+ } catch {
7961
+ done(void 0);
7962
+ return;
7963
+ }
7964
+ const chunks = [];
7965
+ const timer = setTimeout(() => {
7966
+ child.kill("SIGKILL");
7967
+ done(void 0);
7968
+ }, FFMPEG_TIMEOUT_MS);
7969
+ child.stdout?.on("data", (c) => chunks.push(c));
7970
+ child.on("error", () => {
7971
+ clearTimeout(timer);
7972
+ done(void 0);
7973
+ });
7974
+ child.on("close", (code) => {
7975
+ clearTimeout(timer);
7976
+ done(code === 0 && chunks.length > 0 ? Buffer.concat(chunks) : void 0);
7977
+ });
7978
+ });
7979
+ var CRC_TABLE = (() => {
7980
+ const t = new Uint32Array(256);
7981
+ for (let n = 0; n < 256; n++) {
7982
+ let c = n;
7983
+ for (let k = 0; k < 8; k++) c = c & 1 ? c >>> 1 ^ 3988292384 : c >>> 1;
7984
+ t[n] = c >>> 0;
7985
+ }
7986
+ return t;
7987
+ })();
7988
+ function crc32(buf) {
7989
+ let c = 4294967295;
7990
+ for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
7991
+ return (c ^ 4294967295) >>> 0;
7992
+ }
7993
+ function chunk(type, data) {
7994
+ const len = Buffer.alloc(4);
7995
+ len.writeUInt32BE(data.length);
7996
+ const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
7997
+ const crc = Buffer.alloc(4);
7998
+ crc.writeUInt32BE(crc32(typed));
7999
+ return Buffer.concat([len, typed, crc]);
8000
+ }
8001
+ function hash(seed) {
8002
+ let h = 2166136261;
8003
+ for (const ch of Buffer.from(seed, "utf8")) {
8004
+ h ^= ch;
8005
+ h = Math.imul(h, 16777619) >>> 0;
8006
+ }
8007
+ return h;
8008
+ }
8009
+ function hslToRgb(h, s, l) {
8010
+ const a = s * Math.min(l, 1 - l);
8011
+ const f = (n) => {
8012
+ const k = (n + h / 30) % 12;
8013
+ return Math.round(255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))));
8014
+ };
8015
+ return [f(0), f(8), f(4)];
8016
+ }
8017
+ var COVER_W = 320;
8018
+ var COVER_H = 180;
8019
+ function generateGradientCover(seed) {
8020
+ const hue = hash(seed) % 360;
8021
+ const from = hslToRgb(hue, 0.65, 0.45);
8022
+ const to = hslToRgb((hue + 40) % 360, 0.7, 0.62);
8023
+ const rows = [];
8024
+ for (let y = 0; y < COVER_H; y++) {
8025
+ const row = Buffer.alloc(1 + COVER_W * 3);
8026
+ for (let x = 0; x < COVER_W; x++) {
8027
+ const t = (x / (COVER_W - 1) + y / (COVER_H - 1)) / 2;
8028
+ for (let i = 0; i < 3; i++) row[1 + x * 3 + i] = Math.round(from[i] + (to[i] - from[i]) * t);
8029
+ }
8030
+ rows.push(row);
8031
+ }
8032
+ const ihdr = Buffer.alloc(13);
8033
+ ihdr.writeUInt32BE(COVER_W, 0);
8034
+ ihdr.writeUInt32BE(COVER_H, 4);
8035
+ ihdr[8] = 8;
8036
+ ihdr[9] = 2;
8037
+ return Buffer.concat([
8038
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
8039
+ chunk("IHDR", ihdr),
8040
+ chunk("IDAT", deflateSync(Buffer.concat(rows))),
8041
+ chunk("IEND", Buffer.alloc(0))
8042
+ ]);
8043
+ }
8044
+ function isJpeg(b) {
8045
+ return b !== void 0 && b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255;
8046
+ }
8047
+ async function resolveCover(p) {
8048
+ if (p.cover !== void 0) return { file: p.cover, source: "cover" };
8049
+ let frame;
8050
+ try {
8051
+ frame = await p.grab(p.video.path, p.video.durationSec > 1 ? 1 : 0);
8052
+ } catch {
8053
+ frame = void 0;
8054
+ }
8055
+ if (isJpeg(frame)) {
8056
+ return { file: { path: "cover.jpg", name: "cover.jpg", mime: "image/jpeg", bytes: frame }, source: "ffmpeg" };
8057
+ }
8058
+ return {
8059
+ file: { path: "cover.png", name: "cover.png", mime: "image/png", bytes: generateGradientCover(p.seed) },
8060
+ source: "generated"
8061
+ };
8062
+ }
8063
+
8064
+ // src/media/image.ts
8065
+ import { basename, extname } from "node:path";
8066
+ var MAX_IMAGES = 5;
8067
+ var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
8068
+ var BY_EXT = {
8069
+ ".png": "image/png",
8070
+ ".jpg": "image/jpeg",
8071
+ ".jpeg": "image/jpeg",
8072
+ ".gif": "image/gif",
8073
+ ".bmp": "image/bmp"
8074
+ };
8075
+ var MAGIC = {
8076
+ "image/png": [137, 80, 78, 71],
8077
+ "image/jpeg": [255, 216, 255],
8078
+ "image/gif": [71, 73, 70, 56],
8079
+ "image/bmp": [66, 77]
8080
+ };
8081
+ function matchesMagic(bytes, mime) {
8082
+ const sig = MAGIC[mime];
8083
+ return bytes.length >= sig.length && sig.every((b, i) => bytes[i] === b);
8084
+ }
8085
+ function mib(n) {
8086
+ return `${(n / 1024 / 1024).toFixed(1)} MiB`;
8087
+ }
8088
+ function loadImages(paths, readBytes, maxBytes = MAX_IMAGE_BYTES) {
8089
+ if (paths.length > MAX_IMAGES) {
8090
+ throw new PingoError("usage", `--image \u6700\u591A ${MAX_IMAGES} \u5F20,\u5F53\u524D ${paths.length} \u5F20\u3002`);
8091
+ }
8092
+ return paths.map((path) => {
8093
+ const mime = BY_EXT[extname(path).toLowerCase()];
8094
+ let bytes;
8095
+ try {
8096
+ bytes = readBytes(path);
8097
+ } catch (err) {
8098
+ throw new PingoError("usage", `\u65E0\u6CD5\u8BFB\u53D6\u56FE\u7247 ${path}:${err.message}`);
8099
+ }
8100
+ if (mime === void 0) {
8101
+ throw new PingoError("content", `\u4E0D\u652F\u6301\u7684\u56FE\u7247\u683C\u5F0F:${path}(\u652F\u6301 png\u3001jpg\u3001gif\u3001bmp)\u3002`);
8102
+ }
8103
+ if (bytes.length === 0) {
8104
+ throw new PingoError("content", `\u56FE\u7247\u6587\u4EF6\u4E3A\u7A7A:${path}`);
8105
+ }
8106
+ if (!matchesMagic(bytes, mime)) {
8107
+ throw new PingoError("content", `\u56FE\u7247\u5185\u5BB9\u4E0E\u6269\u5C55\u540D\u4E0D\u4E00\u81F4:${path}(\u6587\u4EF6\u5185\u5BB9\u4E0D\u662F ${mime})\u3002`);
8108
+ }
8109
+ if (bytes.length > maxBytes) {
8110
+ throw new PingoError(
8111
+ "content",
8112
+ `\u56FE\u7247\u8FC7\u5927:${path} \u4E3A ${mib(bytes.length)}(${bytes.length} \u5B57\u8282),\u4E0A\u9650 ${maxBytes} \u5B57\u8282(${mib(maxBytes)})\u3002`
8113
+ );
8114
+ }
8115
+ return { path, name: basename(path), mime, bytes };
8116
+ });
8117
+ }
8118
+
8119
+ // src/media/video.ts
8120
+ import { basename as basename2, extname as extname2 } from "node:path";
8121
+ var MAX_VIDEO_BYTES = 19 * 1024 * 1024;
8122
+ function bad(message) {
8123
+ throw new PingoError("content", `\u65E0\u6CD5\u8BFB\u53D6 mp4 \u65F6\u957F:${message}`);
8124
+ }
8125
+ function* boxes(buf, start, end) {
8126
+ let off = start;
8127
+ while (off + 8 <= end) {
8128
+ let size = buf.readUInt32BE(off);
8129
+ const type = buf.toString("latin1", off + 4, off + 8);
8130
+ let header = 8;
8131
+ if (size === 1) {
8132
+ if (off + 16 > end) bad("\u6587\u4EF6\u88AB\u622A\u65AD");
8133
+ const large = buf.readBigUInt64BE(off + 8);
8134
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) bad("box \u5C3A\u5BF8\u8FC7\u5927");
8135
+ size = Number(large);
8136
+ header = 16;
8137
+ } else if (size === 0) {
8138
+ size = end - off;
8139
+ }
8140
+ if (size < header || off + size > end) bad("\u6587\u4EF6\u88AB\u622A\u65AD\u6216\u683C\u5F0F\u9519\u8BEF");
8141
+ yield { type, start: off, contentStart: off + header, end: off + size };
8142
+ off += size;
8143
+ }
8144
+ }
8145
+ function parseMp4Duration(buf) {
8146
+ for (const top of boxes(buf, 0, buf.length)) {
8147
+ if (top.type !== "moov") continue;
8148
+ for (const child of boxes(buf, top.contentStart, top.end)) {
8149
+ if (child.type !== "mvhd") continue;
8150
+ const c = child.contentStart;
8151
+ const version = buf[c];
8152
+ let timescale;
8153
+ let duration;
8154
+ if (version === 1) {
8155
+ if (c + 32 > child.end) bad("mvhd \u88AB\u622A\u65AD");
8156
+ timescale = buf.readUInt32BE(c + 20);
8157
+ duration = Number(buf.readBigUInt64BE(c + 24));
8158
+ } else {
8159
+ if (c + 20 > child.end) bad("mvhd \u88AB\u622A\u65AD");
8160
+ timescale = buf.readUInt32BE(c + 12);
8161
+ duration = buf.readUInt32BE(c + 16);
8162
+ }
8163
+ if (timescale === 0) bad("mvhd \u7684 timescale \u4E3A 0");
8164
+ return Math.max(1, Math.ceil(duration / timescale));
8165
+ }
8166
+ bad("moov \u4E2D\u6CA1\u6709 mvhd");
8167
+ }
8168
+ return bad("\u6587\u4EF6\u4E2D\u6CA1\u6709 moov(\u4E0D\u662F\u5B8C\u6574\u7684 mp4)");
8169
+ }
8170
+ function mib2(n) {
8171
+ return `${(n / 1024 / 1024).toFixed(1)} MiB`;
8172
+ }
8173
+ function loadVideo(path, readBytes) {
8174
+ let bytes;
8175
+ try {
8176
+ bytes = readBytes(path);
8177
+ } catch (err) {
8178
+ throw new PingoError("usage", `\u65E0\u6CD5\u8BFB\u53D6\u89C6\u9891 ${path}:${err.message}`);
8179
+ }
8180
+ if (extname2(path).toLowerCase() !== ".mp4") {
8181
+ throw new PingoError("content", `\u53EA\u652F\u6301 mp4 \u89C6\u9891:${path}`);
8182
+ }
8183
+ if (bytes.length === 0) {
8184
+ throw new PingoError("content", `\u89C6\u9891\u6587\u4EF6\u4E3A\u7A7A:${path}`);
8185
+ }
8186
+ if (bytes.length < 8 || bytes.toString("latin1", 4, 8) !== "ftyp") {
8187
+ throw new PingoError("content", `\u6587\u4EF6\u5185\u5BB9\u4E0D\u662F mp4:${path}`);
8188
+ }
8189
+ if (bytes.length > MAX_VIDEO_BYTES) {
8190
+ throw new PingoError(
8191
+ "content",
8192
+ `\u89C6\u9891\u8FC7\u5927:${path} \u4E3A ${mib2(bytes.length)}(${bytes.length} \u5B57\u8282),\u4E0A\u9650 ${MAX_VIDEO_BYTES} \u5B57\u8282(19 MiB)\u3002`
8193
+ );
8194
+ }
8195
+ return { path, name: basename2(path), bytes, durationSec: parseMp4Duration(bytes) };
8196
+ }
8197
+
7758
8198
  // src/utils/redact.ts
7759
8199
  function maskSecret(value) {
7760
8200
  if (value.length <= 4) return "***";
@@ -7779,8 +8219,15 @@ function redact(text, secrets) {
7779
8219
  // src/providers/dingtalk.ts
7780
8220
  var TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken";
7781
8221
  var SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
8222
+ var CARD_DELIVER_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver";
8223
+ var CARD_UPDATE_URL = "https://api.dingtalk.com/v1.0/card/instances";
8224
+ var UPLOAD_URL = "https://oapi.dingtalk.com/media/upload";
7782
8225
  var TOKEN_REFRESH_MARGIN_SEC = 300;
7783
8226
  var RETRY_DELAYS_MS = [500, 1e3];
8227
+ function uploadTimeoutMs(bytes, baseTimeoutMs) {
8228
+ const estimate = 3e4 + Math.ceil(bytes / (128 * 1024)) * 1e3;
8229
+ return Math.max(baseTimeoutMs, estimate);
8230
+ }
7784
8231
  function defaultSleep(ms) {
7785
8232
  return new Promise((resolve2) => setTimeout(resolve2, ms));
7786
8233
  }
@@ -7790,11 +8237,11 @@ function createDingTalkClient(opts) {
7790
8237
  const sleep = opts.sleep ?? defaultSleep;
7791
8238
  let cached;
7792
8239
  const scrub = (text) => redact(text, [opts.creds.clientSecret, cached?.token ?? ""]);
7793
- async function request(url, init, onFetchError) {
8240
+ async function request(url, init, onFetchError, timeoutMs = opts.timeoutMs) {
7794
8241
  let res;
7795
8242
  let text;
7796
8243
  try {
7797
- res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(opts.timeoutMs) });
8244
+ res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
7798
8245
  text = await res.text();
7799
8246
  } catch (err) {
7800
8247
  throw new PingoError(onFetchError, `\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25:${scrub(err.message)}`);
@@ -7892,7 +8339,247 @@ function createDingTalkClient(opts) {
7892
8339
  const token = await getToken();
7893
8340
  return withRetry(() => sendOnce(token, msg));
7894
8341
  }
7895
- return { getToken, send };
8342
+ function failCard(res) {
8343
+ const code = typeof res.json?.code === "string" ? res.json.code : void 0;
8344
+ const message = typeof res.json?.message === "string" ? res.json.message : `HTTP ${res.status}`;
8345
+ const label = scrub(code ? `${code}: ${message}` : message);
8346
+ const classified = classifyDingTalkError(res.status, code);
8347
+ if (classified === "auth") {
8348
+ throw new PingoError("auth", `\u9489\u9489\u5361\u7247\u63A5\u53E3\u9274\u6743\u5931\u8D25(HTTP ${res.status}):${label}`, code);
8349
+ }
8350
+ if (res.status === 429 || res.status >= 500) {
8351
+ throw new PingoError("transient", `\u9489\u9489\u5361\u7247\u63A5\u53E3\u6682\u65F6\u4E0D\u53EF\u7528(HTTP ${res.status}):${label}`, code);
8352
+ }
8353
+ throw new PingoError("config", `\u9489\u9489\u62D2\u7EDD\u4E86\u5361\u7247(HTTP ${res.status}):${label}`, code, { degradable: true });
8354
+ }
8355
+ async function deliverCardOnce(token, p) {
8356
+ const res = await request(
8357
+ CARD_DELIVER_URL,
8358
+ {
8359
+ method: "POST",
8360
+ headers: { "Content-Type": "application/json", "x-acs-dingtalk-access-token": token },
8361
+ body: JSON.stringify({
8362
+ cardTemplateId: p.templateId,
8363
+ outTrackId: p.outTrackId,
8364
+ cardData: { cardParamMap: p.cardParamMap },
8365
+ callbackType: "STREAM",
8366
+ openSpaceId: `dtv1.card//IM_ROBOT.${opts.creds.userId}`,
8367
+ imRobotOpenSpaceModel: { supportForward: true },
8368
+ imRobotOpenDeliverModel: { spaceType: "IM_ROBOT", robotCode: opts.creds.robotCode },
8369
+ userIdType: 1
8370
+ })
8371
+ },
8372
+ "network_unknown"
8373
+ );
8374
+ if (res.status !== 200) failCard(res);
8375
+ if (res.json === void 0) {
8376
+ throw new PingoError("network_unknown", "\u9489\u9489\u5361\u7247\u54CD\u5E94\u4E3A\u7A7A\u6216\u4E0D\u662F JSON,\u65E0\u6CD5\u786E\u8BA4\u5361\u7247\u662F\u5426\u5DF2\u53D1\u51FA\u3002");
8377
+ }
8378
+ const result = res.json.result;
8379
+ const results = result?.deliverResults ?? [];
8380
+ if (results.length === 0) {
8381
+ throw new PingoError("network_unknown", "\u9489\u9489\u5361\u7247\u54CD\u5E94\u4E2D\u6CA1\u6709\u6295\u9012\u7ED3\u679C,\u65E0\u6CD5\u786E\u8BA4\u5361\u7247\u662F\u5426\u5DF2\u53D1\u51FA\u3002");
8382
+ }
8383
+ const failedItem = results.find((r) => r.success !== true);
8384
+ if (failedItem !== void 0) {
8385
+ throw new PingoError(
8386
+ "config",
8387
+ `\u9489\u9489\u5361\u7247\u6295\u9012\u5931\u8D25:${scrub(failedItem.errorMsg || "\u672A\u77E5\u539F\u56E0")}`,
8388
+ void 0,
8389
+ { degradable: true }
8390
+ );
8391
+ }
8392
+ }
8393
+ async function deliverCard(p) {
8394
+ const token = await getToken();
8395
+ let hadTransient = false;
8396
+ return withRetry(async () => {
8397
+ try {
8398
+ return await deliverCardOnce(token, p);
8399
+ } catch (err) {
8400
+ if (err instanceof PingoError && err.category === "transient") {
8401
+ hadTransient = true;
8402
+ } else if (hadTransient && err instanceof PingoError && err.degradable) {
8403
+ throw new PingoError(
8404
+ "network_unknown",
8405
+ `\u9489\u9489\u5361\u7247\u63A5\u53E3\u5728\u91CD\u8BD5\u540E\u62D2\u7EDD\u4E86\u8BF7\u6C42,\u4E4B\u524D\u7684\u8BF7\u6C42\u53EF\u80FD\u5DF2\u6295\u653E\u6210\u529F:${err.message}`,
8406
+ err.code
8407
+ );
8408
+ }
8409
+ throw err;
8410
+ }
8411
+ });
8412
+ }
8413
+ async function updateCardOnce(token, p) {
8414
+ const res = await request(
8415
+ CARD_UPDATE_URL,
8416
+ {
8417
+ method: "PUT",
8418
+ headers: { "Content-Type": "application/json", "x-acs-dingtalk-access-token": token },
8419
+ body: JSON.stringify({
8420
+ outTrackId: p.outTrackId,
8421
+ cardData: { cardParamMap: p.cardParamMap },
8422
+ cardUpdateOptions: { updateCardDataByKey: true },
8423
+ userIdType: 1
8424
+ })
8425
+ },
8426
+ "network_unknown"
8427
+ );
8428
+ if (res.status !== 200) {
8429
+ try {
8430
+ failCard(res);
8431
+ } catch (err) {
8432
+ if (err instanceof PingoError && err.degradable) throw new PingoError(err.category, err.message, err.code);
8433
+ throw err;
8434
+ }
8435
+ }
8436
+ if (res.json === void 0) {
8437
+ throw new PingoError("network_unknown", "\u9489\u9489\u5361\u7247\u66F4\u65B0\u54CD\u5E94\u4E3A\u7A7A\u6216\u4E0D\u662F JSON,\u65E0\u6CD5\u786E\u8BA4\u662F\u5426\u5DF2\u66F4\u65B0\u3002");
8438
+ }
8439
+ if (res.json.result !== true) {
8440
+ throw new PingoError("config", "\u9489\u9489\u5361\u7247\u66F4\u65B0\u5931\u8D25:cardId \u53EF\u80FD\u65E0\u6548\u6216\u5361\u7247\u5DF2\u4E0D\u53EF\u66F4\u65B0\u3002");
8441
+ }
8442
+ }
8443
+ async function updateCard(p) {
8444
+ const token = await getToken();
8445
+ return withRetry(() => updateCardOnce(token, p));
8446
+ }
8447
+ async function uploadOnce(token, file, type) {
8448
+ const form = new FormData();
8449
+ form.append("media", new Blob([new Uint8Array(file.bytes)], { type: file.mime }), file.name);
8450
+ const url = `${UPLOAD_URL}?access_token=${encodeURIComponent(token)}&type=${type}`;
8451
+ const res = await request(url, { method: "POST", body: form }, "transient", uploadTimeoutMs(file.bytes.length, opts.timeoutMs));
8452
+ if (res.status === 413) {
8453
+ throw new PingoError("content", `\u6587\u4EF6 ${file.name} \u8D85\u8FC7\u9489\u9489\u4E0A\u4F20\u4E0A\u9650(HTTP 413),\u8BF7\u538B\u7F29\u540E\u91CD\u8BD5\u3002`);
8454
+ }
8455
+ if (res.status === 429 || res.status >= 500) {
8456
+ throw new PingoError("transient", `\u9489\u9489\u5A92\u4F53\u4E0A\u4F20\u6682\u65F6\u4E0D\u53EF\u7528(HTTP ${res.status})\u3002`);
8457
+ }
8458
+ const errcode = res.json?.errcode;
8459
+ const mediaId = res.json?.media_id;
8460
+ if (res.status !== 200 || errcode !== 0 || typeof mediaId !== "string" || mediaId === "") {
8461
+ const errmsg = typeof res.json?.errmsg === "string" ? res.json.errmsg : "";
8462
+ throw new PingoError(
8463
+ "content",
8464
+ `\u6587\u4EF6 ${file.name} \u4E0A\u4F20\u5931\u8D25:HTTP ${res.status},errcode=${String(errcode)} ${scrub(errmsg)}`.trim()
8465
+ );
8466
+ }
8467
+ return mediaId;
8468
+ }
8469
+ async function uploadMedia(file, type) {
8470
+ const token = await getToken();
8471
+ return withRetry(() => uploadOnce(token, file, type));
8472
+ }
8473
+ async function uploadImage(file) {
8474
+ return uploadMedia(file, "image");
8475
+ }
8476
+ return { getToken, send, deliverCard, updateCard, uploadMedia, uploadImage };
8477
+ }
8478
+
8479
+ // src/send/fallback.ts
8480
+ function decideFallback(from, err, opts) {
8481
+ if (!(err instanceof PingoError)) return void 0;
8482
+ if (from === "card") return err.degradable ? "markdown" : void 0;
8483
+ if (from === "markdown") {
8484
+ if (isFormatRejection(err.code) && opts.fallbackFormat === "text" && !opts.markdownFromFile) {
8485
+ return "text";
8486
+ }
8487
+ return void 0;
8488
+ }
8489
+ return void 0;
8490
+ }
8491
+
8492
+ // src/send/pipeline.ts
8493
+ function toPingoError(err) {
8494
+ return err instanceof PingoError ? err : new PingoError("transient", String(err?.message ?? err));
8495
+ }
8496
+ async function runPipeline(p, client, newId = newCardId) {
8497
+ const mediaIds = [];
8498
+ for (const image of p.images) {
8499
+ mediaIds.push(await client.uploadMedia(image, "image"));
8500
+ }
8501
+ let videoMsgParam;
8502
+ if (p.video !== void 0) {
8503
+ const picMediaId = await client.uploadMedia(p.video.cover, "image");
8504
+ const videoMediaId = await client.uploadMedia(
8505
+ { bytes: p.video.file.bytes, name: p.video.file.name, mime: "video/mp4" },
8506
+ "video"
8507
+ );
8508
+ videoMsgParam = JSON.stringify({
8509
+ duration: String(p.video.file.durationSec),
8510
+ videoMediaId,
8511
+ videoType: "mp4",
8512
+ picMediaId
8513
+ });
8514
+ }
8515
+ async function sendVideo(sent) {
8516
+ if (videoMsgParam === void 0) return;
8517
+ try {
8518
+ await client.send({ msgKey: "sampleVideo", msgParam: videoMsgParam });
8519
+ sent.push("video");
8520
+ } catch (err) {
8521
+ throw new PartialSendError(toPingoError(err), sent, ["video"]);
8522
+ }
8523
+ }
8524
+ async function attemptCard() {
8525
+ if (p.templateId === void 0) {
8526
+ throw new PingoError("config", "\u4F7F\u7528\u5361\u7247\u683C\u5F0F\u9700\u8981\u914D\u7F6E card.templateId\u3002");
8527
+ }
8528
+ const { cardParamMap } = renderCard(p.input, { device: p.device }, mediaIds);
8529
+ const cardId = newId();
8530
+ await client.deliverCard({ templateId: p.templateId, outTrackId: cardId, cardParamMap });
8531
+ const sent = ["card"];
8532
+ await sendVideo(sent);
8533
+ return { cardId, sent };
8534
+ }
8535
+ async function attemptPlain(format2) {
8536
+ const built = buildMessage(p.input, { device: p.device, templates: p.templates, format: format2 });
8537
+ const result = await client.send({ msgKey: built.msgKey, msgParam: built.msgParam });
8538
+ const sent = ["text-message"];
8539
+ for (let i = 0; i < mediaIds.length; i++) {
8540
+ try {
8541
+ await client.send({ msgKey: "sampleImageMsg", msgParam: JSON.stringify({ photoURL: mediaIds[i] }) });
8542
+ sent.push(`image-${i + 1}`);
8543
+ } catch (err) {
8544
+ const failed = mediaIds.slice(i).map((_, j) => `image-${i + j + 1}`);
8545
+ if (videoMsgParam !== void 0) failed.push("video");
8546
+ throw new PartialSendError(toPingoError(err), sent, failed);
8547
+ }
8548
+ }
8549
+ await sendVideo(sent);
8550
+ return { processQueryKey: result.processQueryKey, sent };
8551
+ }
8552
+ let format = p.format;
8553
+ let degradedFrom;
8554
+ let degradeReason;
8555
+ for (; ; ) {
8556
+ try {
8557
+ const attempt = format === "card" ? await attemptCard() : await attemptPlain(format);
8558
+ return {
8559
+ format,
8560
+ degraded: degradedFrom !== void 0,
8561
+ degradedFrom,
8562
+ degradeReason,
8563
+ cardId: attempt.cardId,
8564
+ processQueryKey: attempt.processQueryKey,
8565
+ images: mediaIds.length,
8566
+ video: videoMsgParam !== void 0,
8567
+ sent: attempt.sent
8568
+ };
8569
+ } catch (err) {
8570
+ if (err instanceof PartialSendError) throw err;
8571
+ const next = decideFallback(format, err, {
8572
+ fallbackFormat: p.fallbackFormat,
8573
+ markdownFromFile: p.markdownFromFile
8574
+ });
8575
+ if (next === void 0) throw err;
8576
+ if (degradedFrom === void 0) {
8577
+ degradedFrom = format;
8578
+ degradeReason = err.message;
8579
+ }
8580
+ format = next;
8581
+ }
8582
+ }
7896
8583
  }
7897
8584
 
7898
8585
  // src/skills/install.ts
@@ -8069,7 +8756,15 @@ function uninstallSkills(opts) {
8069
8756
 
8070
8757
  // src/cli.ts
8071
8758
  var MESSAGE_TYPES2 = ["success", "warning", "error", "info"];
8072
- var FORMATS2 = ["text", "markdown"];
8759
+ var SEND_FORMATS2 = ["card", "markdown", "text"];
8760
+ var FORMAT_LABELS = { card: "\u5361\u7247", markdown: "Markdown", text: "\u6587\u672C" };
8761
+ function sentLabel(item) {
8762
+ if (item === "card") return "\u5361\u7247";
8763
+ if (item === "text-message") return "\u6587\u5B57\u6D88\u606F";
8764
+ if (item === "video") return "\u89C6\u9891";
8765
+ const m = /^image-(\d+)$/.exec(item);
8766
+ return m ? `\u56FE\u7247 ${m[1]}` : item;
8767
+ }
8073
8768
  var USAGE = `pingo \u2014 \u628A\u6D88\u606F\u53D1\u5230\u624B\u673A\u9489\u9489
8074
8769
 
8075
8770
  \u7528\u6CD5:
@@ -8080,14 +8775,25 @@ send \u9009\u9879:
8080
8775
  --title <\u6587\u672C> \u6807\u9898\u884C(\u663E\u793A\u5728\u6B63\u6587\u5F00\u5934)
8081
8776
  --message <\u6587\u672C> \u6D88\u606F\u6B63\u6587
8082
8777
  --markdown <\u6587\u4EF6> \u4ECE Markdown \u6587\u4EF6\u8BFB\u53D6\u6B63\u6587(\u53EA\u80FD\u4EE5 markdown \u683C\u5F0F\u53D1\u9001)
8083
- --format <text|markdown> \u53D1\u9001\u683C\u5F0F(\u9ED8\u8BA4\u53D6\u914D\u7F6E message.preferredFormat)
8778
+ --format <card|markdown|text> \u53D1\u9001\u683C\u5F0F(\u9ED8\u8BA4\u53D6\u914D\u7F6E message.preferredFormat;\u914D\u7F6E\u4E86 card.templateId \u65F6\u9ED8\u8BA4 card)
8084
8779
  --agent <\u540D\u79F0> --project <\u540D\u79F0> --task <\u540D\u79F0>
8085
8780
  --action-required \u6807\u8BB0\u9700\u8981\u4EBA\u5DE5\u5904\u7406
8781
+ --image <\u6587\u4EF6> \u9644\u5E26\u56FE\u7247(png/jpg/gif/bmp,\u53EF\u91CD\u590D,\u6700\u591A 5 \u5F20,\u5355\u5F20 \u2264 10 MiB)
8782
+ --video <\u6587\u4EF6.mp4> \u9644\u5E26\u89C6\u9891(\u2264 19 MiB,\u5728\u4E3B\u6D88\u606F\u4E4B\u540E\u5355\u72EC\u53D1\u9001)
8783
+ --cover <\u56FE\u7247> \u89C6\u9891\u5C01\u9762(\u4E0D\u4F20\u5219\u7528 ffmpeg \u622A\u5E27,\u5931\u8D25\u5219\u81EA\u52A8\u751F\u6210)
8784
+ --date <\u6587\u672C> \u5361\u7247 date \u53D8\u91CF(\u65F6\u95F4,\u4EC5\u5361\u7247)
8785
+ --chart <\u6587\u4EF6.json> \u5361\u7247 chart \u53D8\u91CF(\u56FE\u8868 JSON,type \u53EF\u9009 lineChart|histogram|pieChart,\u5982 {"type":"lineChart","data":[{"x":"N0","type":"line","y":48}],"config":{}},\u4EC5\u5361\u7247)
8786
+ --link "\u6587\u5B57=URL" \u94FE\u63A5\u6309\u94AE(\u53EF\u91CD\u590D,\u6700\u591A 3 \u4E2A,\u4EC5 http(s))
8086
8787
  --config <\u8DEF\u5F84> \u6307\u5B9A\u914D\u7F6E\u6587\u4EF6
8087
8788
  --dry-run \u53EA\u9884\u89C8, \u4E0D\u53D1\u9001(\u4E0D\u9700\u8981\u51ED\u636E)
8088
8789
  --json \u8F93\u51FA\u673A\u5668\u53EF\u8BFB JSON
8089
8790
  --help \u663E\u793A\u672C\u5E2E\u52A9
8090
8791
 
8792
+ pingo update <cardId> [--type ...] [--title ...] [--message ... | --markdown <\u6587\u4EF6>] [--image ...] [--link ...]
8793
+ [--action-required] [--agent ...] [--project ...] [--date ...] [--chart <\u6587\u4EF6>] [--json]
8794
+ \u539F\u5730\u66F4\u65B0 pingo send --json \u8FD4\u56DE\u7684\u5361\u7247\u3002\u53EA\u66F4\u65B0\u4F20\u5165\u7684\u5B57\u6BB5:--type/--title \u91CD\u65B0\u751F\u6210\u6807\u9898;
8795
+ --message/--markdown/--image/--action-required \u6574\u4F53\u66FF\u6362\u6B63\u6587;--agent/--project \u66F4\u65B0\u53D1\u9001\u8005\u884C\u3002
8796
+
8091
8797
  pingo skills install|uninstall [--tool kiro,claude,codex] [--yes]
8092
8798
  \u5B89\u88C5/\u5378\u8F7D pingo\u3001pingo-follow \u4E24\u4E2A Skill \u5230\u6240\u9009 AI \u5DE5\u5177(Kiro / Claude Code / Codex)\u3002
8093
8799
  --tool \u672A\u6307\u5B9A\u4E14\u5728\u4EA4\u4E92\u7EC8\u7AEF\u4E2D\u8FD0\u884C\u65F6\u4F1A\u8BE2\u95EE; \u975E\u4EA4\u4E92\u73AF\u5883\u5FC5\u987B\u663E\u5F0F\u6307\u5B9A\u3002
@@ -8106,6 +8812,8 @@ function defaultDeps() {
8106
8812
  stderr: (s) => process.stderr.write(`${s}
8107
8813
  `),
8108
8814
  readFile: (p) => readFileSync2(p, "utf8"),
8815
+ readBytes: (p) => readFileSync2(p),
8816
+ grabFrame: ffmpegFrameGrabber,
8109
8817
  // src/cli.ts 与打包后的 dist/pingo.js 都位于包根目录下一层, 所以都能这样反推出包目录。
8110
8818
  packageDir: fileURLToPath(new URL("..", import.meta.url)),
8111
8819
  isInteractive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
@@ -8122,18 +8830,24 @@ function defaultDeps() {
8122
8830
  function reportError(r, error) {
8123
8831
  const pingo = error instanceof PingoError ? error : new PingoError("usage", `\u672A\u9884\u671F\u7684\u9519\u8BEF:${error?.message ?? String(error)}`);
8124
8832
  const message = redact(pingo.message, r.secrets);
8833
+ const partial = pingo instanceof PartialSendError ? pingo : void 0;
8125
8834
  if (r.json) {
8126
8835
  r.out(
8127
8836
  JSON.stringify({
8128
8837
  ok: false,
8129
8838
  category: pingo.category,
8130
8839
  code: pingo.code ?? null,
8131
- message
8840
+ message,
8841
+ ...partial ? { sent: partial.sent, failed: partial.failed } : {}
8132
8842
  })
8133
8843
  );
8134
8844
  } else {
8135
8845
  r.err(`\u2717 ${HINTS[pingo.category]}`);
8136
8846
  r.err(` \u539F\u56E0:${message}`);
8847
+ if (partial) {
8848
+ r.err(` \u5DF2\u53D1\u51FA:${partial.sent.map(sentLabel).join("\u3001") || "\u65E0"}`);
8849
+ r.err(` \u672A\u53D1\u51FA:${partial.failed.map(sentLabel).join("\u3001")}(\u5DF2\u53D1\u51FA\u7684\u4E0D\u4F1A\u91CD\u8BD5\u6216\u64A4\u56DE)`);
8850
+ }
8137
8851
  }
8138
8852
  return EXIT_CODES[pingo.category];
8139
8853
  }
@@ -8142,11 +8856,49 @@ function pickEnum2(value, allowed, flag) {
8142
8856
  if (allowed.includes(value)) return value;
8143
8857
  throw new PingoError("usage", `${flag} \u7684\u503C\u4E0D\u5408\u6CD5:${value}(\u53EF\u9009:${allowed.join(", ")})\u3002`);
8144
8858
  }
8859
+ function nonBlank3(value) {
8860
+ return value === void 0 || value.trim() === "" ? void 0 : value;
8861
+ }
8862
+ function loadChart(path, read) {
8863
+ if (path === void 0) return void 0;
8864
+ let raw;
8865
+ try {
8866
+ raw = read(path);
8867
+ } catch (err) {
8868
+ throw new PingoError("usage", `\u65E0\u6CD5\u8BFB\u53D6 --chart \u6587\u4EF6 ${path}:${err.message}`);
8869
+ }
8870
+ return parseChart(raw);
8871
+ }
8872
+ function mibText(n) {
8873
+ return `${(n / 1024 / 1024).toFixed(1)} MiB`;
8874
+ }
8145
8875
  async function runSend(args, d) {
8146
8876
  const r = { secrets: [], json: args.json, out: d.stdout, err: d.stderr };
8147
8877
  try {
8148
8878
  const type = pickEnum2(args.type, MESSAGE_TYPES2, "--type");
8149
- const formatFlag = pickEnum2(args.format, FORMATS2, "--format");
8879
+ const formatFlag = pickEnum2(args.format, SEND_FORMATS2, "--format");
8880
+ if (args.message !== void 0 && args.markdownPath !== void 0) {
8881
+ throw new PingoError("usage", "--message \u4E0E --markdown \u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u3002");
8882
+ }
8883
+ if (args.cover !== void 0 && args.video === void 0) {
8884
+ throw new PingoError("usage", "--cover \u9700\u8981\u4E0E --video \u4E00\u8D77\u4F7F\u7528\u3002");
8885
+ }
8886
+ const links = parseLinks(args.links);
8887
+ const chart = loadChart(args.chart, d.readFile);
8888
+ const images = loadImages(args.images, d.readBytes);
8889
+ const video = args.video !== void 0 ? loadVideo(args.video, d.readBytes) : void 0;
8890
+ const coverFile = args.cover !== void 0 ? loadImages([args.cover], d.readBytes)[0] : void 0;
8891
+ let markdown;
8892
+ if (args.markdownPath !== void 0) {
8893
+ try {
8894
+ markdown = d.readFile(args.markdownPath);
8895
+ } catch (err) {
8896
+ throw new PingoError("usage", `\u65E0\u6CD5\u8BFB\u53D6 --markdown \u6587\u4EF6 ${args.markdownPath}:${err.message}`);
8897
+ }
8898
+ }
8899
+ if (nonBlank3(args.message) === void 0 && nonBlank3(markdown) === void 0) {
8900
+ throw new PingoError("content", "\u6D88\u606F\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A:\u8BF7\u63D0\u4F9B --message \u6216 --markdown\u3002");
8901
+ }
8150
8902
  const loaded = loadConfig({
8151
8903
  configPath: args.config,
8152
8904
  env: d.env,
@@ -8159,16 +8911,13 @@ async function runSend(args, d) {
8159
8911
  const config = loaded.config;
8160
8912
  r.secrets = [config.dingtalk.clientSecret];
8161
8913
  if (!args.json) for (const w of loaded.warnings) d.stderr(`! ${w}`);
8162
- if (args.message !== void 0 && args.markdownPath !== void 0) {
8163
- throw new PingoError("usage", "--message \u4E0E --markdown \u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u3002");
8914
+ const preferred = config.message.preferredFormat;
8915
+ const format = formatFlag ?? (markdown !== void 0 && preferred === "text" ? "markdown" : preferred);
8916
+ if (format === "card" && config.card.templateId === void 0) {
8917
+ throw new PingoError("config", "\u4F7F\u7528\u5361\u7247\u683C\u5F0F\u9700\u8981\u914D\u7F6E card.templateId(\u9489\u9489\u5361\u7247\u6A21\u677F ID)\u3002");
8164
8918
  }
8165
- let markdown;
8166
- if (args.markdownPath !== void 0) {
8167
- try {
8168
- markdown = d.readFile(args.markdownPath);
8169
- } catch (err) {
8170
- throw new PingoError("usage", `\u65E0\u6CD5\u8BFB\u53D6 --markdown \u6587\u4EF6 ${args.markdownPath}:${err.message}`);
8171
- }
8919
+ if (format !== "card" && (args.date !== void 0 || chart !== void 0)) {
8920
+ throw new PingoError("usage", "--date / --chart \u53EA\u80FD\u7528\u4E8E\u5361\u7247\u683C\u5F0F(--format card)\u3002");
8172
8921
  }
8173
8922
  const input = {
8174
8923
  type: type ?? config.message.defaultType,
@@ -8178,21 +8927,51 @@ async function runSend(args, d) {
8178
8927
  agent: args.agent,
8179
8928
  project: args.project,
8180
8929
  task: args.task,
8181
- actionRequired: args.actionRequired
8930
+ actionRequired: args.actionRequired,
8931
+ links,
8932
+ date: args.date,
8933
+ chart
8182
8934
  };
8183
- const format = formatFlag ?? (markdown !== void 0 ? "markdown" : config.message.preferredFormat);
8184
- const ctx = { device: config.device, templates: config.templates, format };
8185
- const built = buildMessage(input, ctx);
8935
+ const placeholders = images.map((_, i) => `<image-${i + 1}>`);
8936
+ const preview = format === "card" ? renderCard(input, { device: config.device }, placeholders) : (() => {
8937
+ const built = buildMessage(input, { device: config.device, templates: config.templates, format });
8938
+ return { preview: built.preview, bytes: built.bytes };
8939
+ })();
8186
8940
  if (args.dryRun) {
8941
+ const coverSource = video === void 0 ? void 0 : coverFile !== void 0 ? "cover" : "ffmpeg(\u5F85\u622A\u53D6,\u5931\u8D25\u5219\u751F\u6210)";
8187
8942
  if (args.json) {
8188
- d.stdout(JSON.stringify({ ok: true, dryRun: true, format: built.format, bytes: built.bytes, preview: built.preview }));
8943
+ d.stdout(
8944
+ JSON.stringify({
8945
+ ok: true,
8946
+ dryRun: true,
8947
+ format,
8948
+ bytes: preview.bytes,
8949
+ preview: preview.preview,
8950
+ images: images.length,
8951
+ video: video !== void 0,
8952
+ ...video ? { videoDurationSec: video.durationSec, coverSource } : {}
8953
+ })
8954
+ );
8189
8955
  } else {
8190
- d.stdout(`[dry-run] \u683C\u5F0F:${built.format} | \u5927\u5C0F:${built.bytes} \u5B57\u8282 | \u672A\u53D1\u9001`);
8956
+ d.stdout(`[dry-run] \u683C\u5F0F:${format} | \u5927\u5C0F:${preview.bytes} \u5B57\u8282 | \u56FE\u7247:${images.length} \u5F20 | \u672A\u53D1\u9001`);
8191
8957
  d.stdout("");
8192
- d.stdout(built.preview);
8958
+ d.stdout(preview.preview);
8959
+ if (format === "card" && nonBlank3(args.date) !== void 0) d.stdout(`
8960
+ date:${args.date}`);
8961
+ if (format === "card" && chart !== void 0) d.stdout(`
8962
+ chart:${chart}`);
8963
+ if (format === "card" && links.length > 0) {
8964
+ d.stdout("");
8965
+ d.stdout(`\u6309\u94AE:${links.map((l) => `${l.text} \u2192 ${l.url}`).join(" | ")}`);
8966
+ }
8967
+ images.forEach((img, i) => d.stdout(`\u56FE\u7247 ${i + 1}:${img.name}(${img.bytes.length} \u5B57\u8282,${img.mime})`));
8968
+ if (video) {
8969
+ d.stdout(`\u89C6\u9891:${video.name}(${mibText(video.bytes.length)},${video.durationSec} \u79D2,\u5C01\u9762:${coverSource})`);
8970
+ }
8193
8971
  }
8194
8972
  return 0;
8195
8973
  }
8974
+ const cover = video ? await resolveCover({ cover: coverFile, video, seed: config.device.id, grab: d.grabFrame }) : void 0;
8196
8975
  const client = createDingTalkClient({
8197
8976
  creds: config.dingtalk,
8198
8977
  timeoutMs: config.message.timeoutMs,
@@ -8200,26 +8979,128 @@ async function runSend(args, d) {
8200
8979
  sleep: d.sleep,
8201
8980
  now: d.now
8202
8981
  });
8203
- let degraded = false;
8204
- let result;
8205
- let sentFormat = built.format;
8206
- try {
8207
- result = await client.send(built);
8208
- } catch (err) {
8209
- const canDegrade = err instanceof PingoError && isFormatRejection(err.code) && built.format === "markdown" && markdown === void 0 && config.message.fallbackFormat === "text";
8210
- if (!canDegrade) throw err;
8211
- const fallback = buildMessage(input, { ...ctx, format: "text" });
8212
- result = await client.send(fallback);
8213
- degraded = true;
8214
- sentFormat = "text";
8215
- }
8982
+ const result = await runPipeline(
8983
+ {
8984
+ input,
8985
+ images,
8986
+ format,
8987
+ device: config.device,
8988
+ templates: config.templates,
8989
+ templateId: config.card.templateId,
8990
+ fallbackFormat: config.message.fallbackFormat,
8991
+ markdownFromFile: markdown !== void 0,
8992
+ video: video && cover ? { file: video, cover: cover.file } : void 0
8993
+ },
8994
+ client,
8995
+ d.newCardId
8996
+ );
8216
8997
  if (args.json) {
8217
8998
  d.stdout(
8218
- JSON.stringify({ ok: true, format: sentFormat, degraded, processQueryKey: result.processQueryKey })
8999
+ JSON.stringify({
9000
+ ok: true,
9001
+ format: result.format,
9002
+ degraded: result.degraded,
9003
+ degradedFrom: result.degradedFrom,
9004
+ cardId: result.cardId,
9005
+ processQueryKey: result.processQueryKey,
9006
+ images: result.images,
9007
+ video: result.video
9008
+ })
8219
9009
  );
8220
9010
  } else {
8221
- d.stdout(`\u2713 \u9489\u9489\u5DF2\u63A5\u53D7\u8BF7\u6C42(${sentFormat}),processQueryKey: ${result.processQueryKey.slice(0, 8)}\u2026`);
8222
- if (degraded) d.stdout("! Markdown \u88AB\u9489\u9489\u62D2\u7EDD,\u5DF2\u964D\u7EA7\u4E3A\u6587\u672C\u91CD\u53D1\u3002");
9011
+ const parts = [`\u2713 \u9489\u9489\u5DF2\u63A5\u53D7\u8BF7\u6C42(${FORMAT_LABELS[result.format]})`];
9012
+ if (result.cardId) parts.push(`cardId: ${result.cardId}`);
9013
+ else if (result.processQueryKey) parts.push(`processQueryKey: ${result.processQueryKey.slice(0, 8)}\u2026`);
9014
+ if (result.images > 0) parts.push(`\u56FE\u7247 ${result.images} \u5F20`);
9015
+ if (result.video) parts.push(`\u89C6\u9891 1 \u6761${cover && cover.source !== "cover" ? `(\u5C01\u9762:${cover.source})` : ""}`);
9016
+ d.stdout(parts.join(","));
9017
+ if (result.degradedFrom) {
9018
+ d.stdout(
9019
+ `! ${FORMAT_LABELS[result.degradedFrom]} \u53D1\u9001\u5931\u8D25,\u5DF2\u964D\u7EA7\u4E3A${FORMAT_LABELS[result.format]}:${redact(result.degradeReason ?? "", r.secrets)}`
9020
+ );
9021
+ }
9022
+ }
9023
+ return 0;
9024
+ } catch (error) {
9025
+ return reportError(r, error);
9026
+ }
9027
+ }
9028
+ async function runUpdate(args, d) {
9029
+ const r = { secrets: [], json: args.json, out: d.stdout, err: d.stderr };
9030
+ try {
9031
+ const cardId = args.positionals[1];
9032
+ if (cardId === void 0) {
9033
+ throw new PingoError("usage", "\u7528\u6CD5:pingo update <cardId> [--type ...] [--title ...] [--message ...] ...");
9034
+ }
9035
+ if (!isCardId(cardId)) {
9036
+ throw new PingoError("usage", `cardId \u683C\u5F0F\u4E0D\u6B63\u786E:${cardId}(\u5E94\u4E3A pingo send --json \u8FD4\u56DE\u7684 cardId)\u3002`);
9037
+ }
9038
+ if (args.format !== void 0) throw new PingoError("usage", "pingo update \u53EA\u7528\u4E8E\u5361\u7247,\u4E0D\u652F\u6301 --format\u3002");
9039
+ if (args.video !== void 0 || args.cover !== void 0) {
9040
+ throw new PingoError("usage", "pingo update \u4E0D\u652F\u6301 --video / --cover,\u8BF7\u7528 pingo send \u53D1\u9001\u89C6\u9891\u3002");
9041
+ }
9042
+ if (args.dryRun) throw new PingoError("usage", "pingo update \u4E0D\u652F\u6301 --dry-run\u3002");
9043
+ if (args.message !== void 0 && args.markdownPath !== void 0) {
9044
+ throw new PingoError("usage", "--message \u4E0E --markdown \u4E0D\u80FD\u540C\u65F6\u4F7F\u7528\u3002");
9045
+ }
9046
+ const type = pickEnum2(args.type, MESSAGE_TYPES2, "--type");
9047
+ const links = args.links.length > 0 ? parseLinks(args.links) : void 0;
9048
+ const chart = loadChart(args.chart, d.readFile);
9049
+ const images = loadImages(args.images, d.readBytes);
9050
+ let markdown;
9051
+ if (args.markdownPath !== void 0) {
9052
+ try {
9053
+ markdown = d.readFile(args.markdownPath);
9054
+ } catch (err) {
9055
+ throw new PingoError("usage", `\u65E0\u6CD5\u8BFB\u53D6 --markdown \u6587\u4EF6 ${args.markdownPath}:${err.message}`);
9056
+ }
9057
+ }
9058
+ const fields = {
9059
+ type,
9060
+ title: args.title,
9061
+ message: args.message,
9062
+ markdown,
9063
+ actionRequired: args.actionRequired ? true : void 0,
9064
+ links,
9065
+ agent: args.agent,
9066
+ project: args.project,
9067
+ task: args.task,
9068
+ date: args.date,
9069
+ chart
9070
+ };
9071
+ renderCardUpdate({ ...fields, defaultType: "info" }, { device: { id: "", name: "" } }, images.map(() => "<image>"));
9072
+ const loaded = loadConfig({
9073
+ configPath: args.config,
9074
+ env: d.env,
9075
+ cwd: d.cwd,
9076
+ homeDir: d.homeDir,
9077
+ platform: d.platform,
9078
+ hostname: d.hostname,
9079
+ requireCredentials: true
9080
+ });
9081
+ const config = loaded.config;
9082
+ r.secrets = [config.dingtalk.clientSecret];
9083
+ if (!args.json) for (const w of loaded.warnings) d.stderr(`! ${w}`);
9084
+ const client = createDingTalkClient({
9085
+ creds: config.dingtalk,
9086
+ timeoutMs: config.message.timeoutMs,
9087
+ fetchImpl: d.fetchImpl,
9088
+ sleep: d.sleep,
9089
+ now: d.now
9090
+ });
9091
+ const mediaIds = [];
9092
+ for (const image of images) mediaIds.push(await client.uploadMedia(image, "image"));
9093
+ const cardParamMap = renderCardUpdate(
9094
+ { ...fields, defaultType: config.message.defaultType },
9095
+ { device: config.device },
9096
+ mediaIds
9097
+ );
9098
+ await client.updateCard({ outTrackId: cardId, cardParamMap });
9099
+ const updated = Object.keys(cardParamMap);
9100
+ if (args.json) {
9101
+ d.stdout(JSON.stringify({ ok: true, cardId, updated }));
9102
+ } else {
9103
+ d.stdout(`\u2713 \u5361\u7247\u5DF2\u66F4\u65B0(${cardId}):${updated.join("\u3001")}`);
8223
9104
  }
8224
9105
  return 0;
8225
9106
  } catch (error) {
@@ -8236,7 +9117,13 @@ var STRING_OPTIONS = /* @__PURE__ */ new Set([
8236
9117
  "project",
8237
9118
  "task",
8238
9119
  "config",
8239
- "tool"
9120
+ "tool",
9121
+ "image",
9122
+ "link",
9123
+ "video",
9124
+ "cover",
9125
+ "date",
9126
+ "chart"
8240
9127
  ]);
8241
9128
  var KNOWN_OPTIONS = /* @__PURE__ */ new Set([
8242
9129
  ...STRING_OPTIONS,
@@ -8323,9 +9210,9 @@ function printResults(d, results) {
8323
9210
  let hasProblem = false;
8324
9211
  for (const r of results) {
8325
9212
  const label = r.status === "created" ? "\u5DF2\u521B\u5EFA" : r.status === "skipped" ? "\u5DF2\u5B58\u5728, \u8DF3\u8FC7" : r.status === "replaced" ? "\u5DF2\u4FEE\u590D\u8FC7\u671F\u94FE\u63A5" : r.status === "removed" ? "\u5DF2\u5220\u9664" : r.status === "failed" ? `\u5931\u8D25:${r.detail ?? "\u672A\u77E5\u539F\u56E0"}` : "\u51B2\u7A81, \u8DF3\u8FC7(\u8BE5\u8DEF\u5F84\u4E0D\u662F\u672C\u5305\u521B\u5EFA\u7684\u94FE\u63A5)";
8326
- const bad = r.status === "conflict" || r.status === "failed";
8327
- d.stdout(`${bad ? "\u2717" : "\u2713"} [${TOOL_LABELS[r.tool]}] ${r.name}:${label} (${r.path})`);
8328
- if (bad) hasProblem = true;
9213
+ const bad2 = r.status === "conflict" || r.status === "failed";
9214
+ d.stdout(`${bad2 ? "\u2717" : "\u2713"} [${TOOL_LABELS[r.tool]}] ${r.name}:${label} (${r.path})`);
9215
+ if (bad2) hasProblem = true;
8329
9216
  }
8330
9217
  return hasProblem;
8331
9218
  }
@@ -8556,10 +9443,22 @@ function parseSendArgs(argv) {
8556
9443
  json: { type: "boolean" },
8557
9444
  help: { type: "boolean" },
8558
9445
  tool: { type: "string" },
8559
- yes: { type: "boolean" }
9446
+ yes: { type: "boolean" },
9447
+ image: { type: "string", multiple: true },
9448
+ link: { type: "string", multiple: true },
9449
+ video: { type: "string" },
9450
+ cover: { type: "string" },
9451
+ date: { type: "string" },
9452
+ chart: { type: "string" }
8560
9453
  }
8561
9454
  });
8562
9455
  return {
9456
+ images: values.image ?? [],
9457
+ links: values.link ?? [],
9458
+ video: values.video,
9459
+ cover: values.cover,
9460
+ date: values.date,
9461
+ chart: values.chart,
8563
9462
  positionals,
8564
9463
  help: values.help === true,
8565
9464
  type: values.type,
@@ -8592,6 +9491,7 @@ async function runCli(argv, overrides = {}) {
8592
9491
  return 0;
8593
9492
  }
8594
9493
  if (command === "send") return runSend(args, d);
9494
+ if (command === "update") return runUpdate(args, d);
8595
9495
  if (command === "config") return runConfig(argv.slice(1), d);
8596
9496
  if (command === "doctor") return runDoctor(argv.slice(1), d);
8597
9497
  if (command === "skills") return runSkills(argv.slice(1), d);