oh-my-dws 0.0.2 → 0.0.3

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/src/actions.js CHANGED
@@ -6,6 +6,9 @@ const { spawnSync } = require('node:child_process');
6
6
 
7
7
  const P = require('./paths');
8
8
  const { runDws, PullError } = require('./dws');
9
+ const REV = require('./rev');
10
+ const { myOpenId } = require('./auth');
11
+ const SETTINGS = require('./settings');
9
12
  const {
10
13
  fmtMinute,
11
14
  fmtSecond,
@@ -90,13 +93,37 @@ function pullGroup(short) {
90
93
  return t !== 0 ? t : String(a.messageId || '').localeCompare(String(b.messageId || ''));
91
94
  });
92
95
 
96
+ // 撤回识别:① 钉钉返回了「撤回」占位文案 ② 这条消息原本有正文,现在被清空
97
+ const fetchedById = new Map();
98
+ for (const m of fetched.messages || []) if (m.messageId) fetchedById.set(m.messageId, m);
99
+ for (const m of merged) {
100
+ if (m.recalled) continue;
101
+ const now = fetchedById.get(m.messageId);
102
+ const txt = String((now && now.text) || m.text || '');
103
+ const looksRecalled = /撤回了一条消息|^\[已撤回\]|^\[撤回\]/.test(txt);
104
+ const emptied = now && !String(now.text || '').trim() && String(m.text || '').trim();
105
+ if (looksRecalled || emptied) {
106
+ m.recalled = true;
107
+ m.recalledAt = fmtSecond(new Date());
108
+ m.recalledBy = m.recalledBy || '';
109
+ m.text = '';
110
+ delete m.quotedMessage;
111
+ }
112
+ }
113
+
93
114
  doc.messages = merged;
94
115
  doc.count = merged.length;
95
116
  doc.complete = fetched.complete === undefined ? true : Boolean(fetched.complete);
96
117
  doc.hasMore = Boolean(fetched.hasMore);
97
118
  doc.queryRange = fetched.queryRange || doc.queryRange;
98
119
  doc.lastPullAt = fmtSecond(now);
99
- doc.newCount = Number(doc.newCount || 0) + added.length;
120
+ // 未读只算别人发的:我自己发的消息不点红点
121
+ const selfId = myOpenId();
122
+ for (const m of added) {
123
+ if (!selfId || String(m.senderId || '') !== selfId) m.fresh = true;
124
+ }
125
+ const othersAdded = added.filter((m) => !selfId || String(m.senderId || '') !== selfId).length;
126
+ doc.newCount = Number(doc.newCount || 0) + othersAdded;
100
127
  doc.lastAddedAt = added.length ? fmtSecond(now) : doc.lastAddedAt || '';
101
128
 
102
129
  let mem = null;
@@ -109,6 +136,7 @@ function pullGroup(short) {
109
136
  }
110
137
 
111
138
  writeArchive(short, doc, mem);
139
+ REV.bump('pull');
112
140
  return {
113
141
  short,
114
142
  added: added.length,
@@ -173,8 +201,15 @@ function markSeen(short) {
173
201
  const doc = loadMessages(short);
174
202
  if (!doc || !Object.keys(doc).length) throw new PullError(`归档为空:${short}`);
175
203
  doc.newCount = 0;
204
+ let cleared = 0;
205
+ for (const m of doc.messages || []) {
206
+ if (m.fresh) {
207
+ delete m.fresh;
208
+ cleared += 1;
209
+ }
210
+ }
176
211
  writeJson(path.join(P.RAW_DIR, short + P.MSG_SUFFIX), doc);
177
- return { short, newCount: 0 };
212
+ return { short, newCount: 0, cleared };
178
213
  }
179
214
 
180
215
  // ---------------------------------------------------------------- @我 聚合
@@ -183,73 +218,96 @@ function mentionsSources() {
183
218
  return shortNames().filter((s) => archiveKind(s) === 'group');
184
219
  }
185
220
 
186
- function refreshMentions() {
187
- const sources = mentionsSources();
188
- if (!sources.length && !shortNames().includes(P.MENTIONS_SHORT)) {
189
- throw new PullError('还没有已归档的群,无法汇总 @我');
221
+ /** cid → 已归档的群简称 */
222
+ function groupShortByCid() {
223
+ const map = new Map();
224
+ for (const s of shortNames()) {
225
+ if (archiveKind(s) !== 'group') continue;
226
+ const cid = conversationId(s);
227
+ if (cid) map.set(cid, s);
190
228
  }
191
- const old = loadMessages(P.MENTIONS_SHORT);
229
+ return map;
230
+ }
231
+
232
+ /**
233
+ * 汇总 @我:一次 `chat +at-me`(不带 --group)就能覆盖所有会话,
234
+ * **包括还没归档的群**——这些群在侧栏来源里标「未归档」,点开时再自动归档。
235
+ *
236
+ * 只从「起点日」往今天累积(doc.since):第一次汇总把起点定在今天,
237
+ * 以后新来的 @我 往上加,很久以前的历史不再往回捞。
238
+ */
239
+ function refreshMentions({ reset = false } = {}) {
240
+ const old = reset ? { messages: [], sources: [] } : loadMessages(P.MENTIONS_SHORT);
241
+ const today = fmtSecond(new Date()).slice(0, 10);
242
+ const since = reset ? today : (old.since || today);
192
243
  const index = new Map();
193
244
  for (const m of old.messages || []) if (m.messageId) index.set(m.messageId, m);
245
+
246
+ // 回溯天数:从起点日(含)算到今天,最多 3650 天
194
247
  const now = new Date();
248
+ const sinceDate = parseLocal(`${since} 00:00:00`);
249
+ const days = sinceDate
250
+ ? Math.min(3650, Math.max(1, Math.floor((now.getTime() - sinceDate.getTime()) / 86400000) + 1))
251
+ : 1;
252
+
253
+ const data = runDws([
254
+ 'chat', '+at-me', '--days', String(days),
255
+ '--page-all', '--page-limit', '50', '--no-reactions', '--format', 'json',
256
+ ]);
257
+ if (data.failedCount) {
258
+ throw new PullError(`dws 分页存在失败项:${JSON.stringify((data.failures || []).slice(0, 3))}`);
259
+ }
260
+
261
+ const byCid = groupShortByCid();
195
262
  const added = [];
196
- const errors = [];
263
+ const sources = new Map(); // cid -> {short, cid, name}
197
264
 
198
- for (const short of sources) {
199
- const cid = conversationId(short);
265
+ for (const it of data.items || data.messages || []) {
266
+ const mid = it.messageId;
267
+ if (!mid) continue;
268
+ const conv = it.conversation || {};
269
+ const cid = String(it.conversationId || conv.openConversationId || '');
200
270
  if (!cid) continue;
201
- const gtitle = groupTitle(short);
202
- const gmsgs = loadMessages(short).messages || [];
203
- // 回溯天数按该群最早一条消息算,避免默认 7 天漏历史
204
- const first = gmsgs.length ? parseLocal(gmsgs[0].createTime) : null;
205
- const days = first
206
- ? Math.min(3650, Math.max(1, Math.floor((now.getTime() - first.getTime()) / 86400000) + 2))
207
- : 7;
208
-
209
- let data;
210
- try {
211
- data = runDws([
212
- 'chat', '+at-me', '--group', cid, '--days', String(days),
213
- '--page-all', '--no-reactions', '--format', 'json',
214
- ]);
215
- } catch (err) {
216
- errors.push({ short, error: err.message });
271
+ const short = byCid.get(cid) || '';
272
+ const name = conv.name || short || cid;
273
+ if (!sources.has(cid)) sources.set(cid, { short, cid, name });
274
+
275
+ if (index.has(mid)) {
276
+ const hit = index.get(mid);
277
+ // 之前没归档的群现在归档了,补上 groupShort,便于按来源群筛选
278
+ if (short && !hit.groupShort) hit.groupShort = short;
217
279
  continue;
218
280
  }
219
-
220
- for (const it of data.items || data.messages || []) {
221
- const mid = it.messageId;
222
- if (!mid || index.has(mid)) continue;
223
- const conv = it.conversation || {};
224
- const m = {
225
- conversationId: it.conversationId || cid,
226
- messageId: mid,
227
- sender: it.sender || '',
228
- senderId: it.senderId || '',
229
- text: it.text || '',
230
- createTime: it.time || it.createTime || '',
231
- groupName: conv.name || gtitle,
232
- groupShort: short,
233
- };
234
- index.set(mid, m);
235
- added.push(m);
236
- }
281
+ const m = {
282
+ conversationId: cid,
283
+ messageId: mid,
284
+ sender: it.sender || '',
285
+ senderId: it.senderId || '',
286
+ text: it.text || '',
287
+ createTime: it.time || it.createTime || '',
288
+ groupName: name,
289
+ groupCid: cid,
290
+ groupShort: short,
291
+ };
292
+ index.set(mid, m);
293
+ added.push(m);
237
294
  }
238
295
 
239
- const keep = new Set(sources);
296
+ // 丢掉早于起点日的(历史包袱不往回捞)
240
297
  const merged = [...index.values()]
241
- .filter((m) => keep.has(m.groupShort || ''))
298
+ .filter((m) => !since || String(m.createTime || '') >= since)
242
299
  .sort((a, b) => {
243
- const t = String(a.createTime || '').localeCompare(String(b.createTime || ''));
244
- return t !== 0 ? t : String(a.messageId || '').localeCompare(String(b.messageId || ''));
245
- });
300
+ const t = String(a.createTime || '').localeCompare(String(b.createTime || ''));
301
+ return t !== 0 ? t : String(a.messageId || '').localeCompare(String(b.messageId || ''));
302
+ });
246
303
 
247
304
  const doc = {
248
305
  conversationId: '@mentions',
249
306
  kind: 'mentions',
307
+ since,
250
308
  messages: merged,
251
309
  count: merged.length,
252
- complete: errors.length === 0,
310
+ complete: data.complete === undefined ? true : Boolean(data.complete),
253
311
  hasMore: false,
254
312
  queryRange: {
255
313
  startTime: merged.length ? merged[0].createTime : '',
@@ -257,11 +315,12 @@ function refreshMentions() {
257
315
  },
258
316
  lastPullAt: fmtSecond(now),
259
317
  newCount: Number(old.newCount || 0) + added.length,
260
- sources: sources.map((s) => ({
261
- short: s,
262
- name: groupTitle(s),
263
- cid: conversationId(s),
264
- })),
318
+ // 来源:只留真的有 @我 消息的会话(已归档的在前)
319
+ sources: [...sources.values()]
320
+ .filter((s2) => merged.some((m) => (m.groupCid || '') === s2.cid))
321
+ .sort((a, b) =>
322
+ (b.short ? 1 : 0) - (a.short ? 1 : 0) || String(a.name).localeCompare(String(b.name), 'zh')
323
+ ),
265
324
  };
266
325
 
267
326
  ensureDir(gdir(P.MENTIONS_SHORT));
@@ -270,16 +329,18 @@ function refreshMentions() {
270
329
  fs.writeFileSync(infoPath, '# @我的消息\n', 'utf8');
271
330
  }
272
331
  writeArchive(P.MENTIONS_SHORT, doc, null);
332
+ REV.bump('mentions');
273
333
 
274
334
  return {
275
335
  short: P.MENTIONS_SHORT,
336
+ since,
276
337
  added: added.length,
277
338
  total: merged.length,
278
339
  complete: doc.complete,
279
340
  hasMore: false,
280
341
  window: [doc.queryRange.startTime, doc.queryRange.endTime],
281
342
  newIds: added.map((m) => m.messageId),
282
- errors,
343
+ errors: [],
283
344
  };
284
345
  }
285
346
 
@@ -290,6 +351,10 @@ function dmPeer(short) {
290
351
  if (doc.peerOpenId) return [doc.peerOpenId, groupTitle(short)];
291
352
  const parts = participants(doc.messages || []);
292
353
  if (parts.length === 1) return [parts[0][0], parts[0][1]];
354
+ // 单聊里有两个参与者时,排除自己
355
+ const me = myOpenId();
356
+ const others = parts.filter(([id]) => id && id !== me);
357
+ if (others.length === 1) return [others[0][0], others[0][1]];
293
358
  return [null, groupTitle(short)];
294
359
  }
295
360
 
@@ -345,6 +410,440 @@ async function sendMessage(short, content, aiTag = false) {
345
410
  return { sent: true, target, verified, messageId, added };
346
411
  }
347
412
 
413
+ // ---------------------------------------------------------------- 事件驱动的增量写入
414
+
415
+ /**
416
+ * 把实时事件里的一条消息直接写进归档(不调消息接口,省 API 额度)。
417
+ * 去重按 messageId;返回 { added } 或 { duplicate }。
418
+ */
419
+ function appendMessage(short, msg) {
420
+ const mid = String((msg && msg.messageId) || '').trim();
421
+ if (!mid) return { skipped: 'no-message-id' };
422
+ if (!shortNames().includes(short)) return { skipped: 'unknown-short' };
423
+ if (archiveKind(short) === 'mentions') return { skipped: 'mentions' };
424
+
425
+ const doc = loadMessages(short);
426
+ const list = doc.messages || [];
427
+ if (list.some((m) => String(m.messageId || '') === mid)) return { duplicate: true };
428
+
429
+ const sender = String(msg.sender || '');
430
+ const senderId = String(msg.senderId || '');
431
+ const createTime = String(msg.createTime || '') || fmtSecond(new Date());
432
+ const text = String(msg.text || '');
433
+ if (!sender && !senderId && !text) return { skipped: 'empty' };
434
+
435
+ list.push({
436
+ conversationId: String(msg.conversationId || conversationId(short) || ''),
437
+ createTime,
438
+ messageId: mid,
439
+ sender,
440
+ senderId,
441
+ text,
442
+ });
443
+ list.sort((a, b) => {
444
+ const t = String(a.createTime || '').localeCompare(String(b.createTime || ''));
445
+ return t !== 0 ? t : String(a.messageId || '').localeCompare(String(b.messageId || ''));
446
+ });
447
+
448
+ doc.messages = list;
449
+ doc.count = list.length;
450
+ // 自己发的不计未读(手动在钉钉里发的也会回灌到归档,只是不亮红点)
451
+ const selfId = myOpenId();
452
+ if (!selfId || senderId !== selfId) {
453
+ doc.newCount = Number(doc.newCount || 0) + 1;
454
+ list[list.length - 1].fresh = true;
455
+ }
456
+ doc.lastAddedAt = fmtSecond(new Date());
457
+ doc.queryRange = {
458
+ startTime: list.length ? list[0].createTime : '',
459
+ endTime: list.length ? list[list.length - 1].createTime : '',
460
+ };
461
+ writeArchive(short, doc, null);
462
+ REV.bump('event');
463
+ return { added: 1, messageId: mid };
464
+ }
465
+
466
+ // ---------------------------------------------------------------- 按人发单聊(常用联系人)
467
+
468
+ /** 找到这个人对应的单聊归档(没有就返回空,不影响发送) */
469
+ function dmArchiveOf(openId) {
470
+ for (const s of shortNames()) {
471
+ if (archiveKind(s) !== 'dm') continue;
472
+ const doc = loadMessages(s);
473
+ if (String(doc.peerOpenId || '') === openId) return s;
474
+ const parts = participants(doc.messages || []);
475
+ if (parts.length === 1 && parts[0][0] === openId) return s;
476
+ }
477
+ return '';
478
+ }
479
+
480
+ /** 给某个人发单聊:直接用 openDingTalkId,不依赖是否已归档该单聊 */
481
+ async function sendToPerson(openId, content, aiTag = false) {
482
+ const oid = String(openId || '').trim();
483
+ if (!oid) throw new PullError('缺少 openDingTalkId');
484
+ const text = String(content || '').trim();
485
+ if (!text) throw new PullError('消息内容为空');
486
+ if (text.length > 4000) throw new PullError('消息过长(>4000 字)');
487
+
488
+ const args = ['chat', '+messages-send', '--open-dingtalk-id', oid, '--text', text, '--format', 'json'];
489
+ if (!aiTag) args.push('--ai-tag', 'false');
490
+
491
+ runDws([...args, '--dry-run']); // 先校验目标可解析
492
+ runDws([...args, '--yes'], { timeout: 90000 });
493
+
494
+ // 若已有该单聊归档,顺手回读确认落库
495
+ const short = dmArchiveOf(oid);
496
+ let verified = false;
497
+ let messageId = '';
498
+ if (short) {
499
+ try {
500
+ pullGroup(short);
501
+ const msgs = loadMessages(short).messages || [];
502
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
503
+ if (String(msgs[i].text || '').trim() === text) {
504
+ messageId = msgs[i].messageId || '';
505
+ verified = true;
506
+ break;
507
+ }
508
+ }
509
+ } catch {
510
+ /* 回读失败不影响已发出 */
511
+ }
512
+ }
513
+
514
+ return { sent: true, target: `单聊:${oid}`, verified, messageId, short, added: 0 };
515
+ }
516
+
517
+ // ---------------------------------------------------------------- 私聊聚合
518
+
519
+ /**
520
+ * 所有私聊消息汇总成一条时间线(只汇总已归档的单聊,也就是点开过 / 在常用联系人里点开的)。
521
+ * 返回 { messages, peers }:peers 用来渲染「哪些人找我说话」。
522
+ */
523
+ function dmTimeline({ limit = 5000 } = {}) {
524
+ const peers = [];
525
+ const out = [];
526
+ const me = myOpenId();
527
+ for (const short of shortNames()) {
528
+ if (archiveKind(short) !== 'dm') continue;
529
+ const doc = loadMessages(short);
530
+ const msgs = doc.messages || [];
531
+ if (!msgs.length) continue;
532
+ const parts = participants(msgs);
533
+ const peerOpenId = String(
534
+ doc.peerOpenId || (parts.find(([id]) => id && id !== me) || [''])[0] || ''
535
+ );
536
+ const name = groupTitle(short) || short;
537
+ let fromPeer = 0;
538
+ let unread = 0;
539
+ let lastTime = '';
540
+ for (const m of msgs) {
541
+ if (String(m.senderId || '') === me) continue;
542
+ fromPeer += 1;
543
+ if (m.fresh) unread += 1;
544
+ lastTime = m.createTime || lastTime;
545
+ out.push({
546
+ conversationId: m.conversationId || '',
547
+ messageId: m.messageId || '',
548
+ sender: m.sender || name,
549
+ senderId: m.senderId || '',
550
+ text: m.text || '',
551
+ createTime: m.createTime || '',
552
+ recalled: m.recalled || false,
553
+ fresh: Boolean(m.fresh),
554
+ recalledBy: m.recalledBy || '',
555
+ recalledAt: m.recalledAt || '',
556
+ groupShort: short,
557
+ groupName: name,
558
+ peerOpenId,
559
+ });
560
+ }
561
+ peers.push({ short, name, openId: peerOpenId, count: fromPeer, unread, lastTime });
562
+ }
563
+ out.sort((a, b) => {
564
+ const t = String(a.createTime || '').localeCompare(String(b.createTime || ''));
565
+ return t !== 0 ? t : String(a.messageId || '').localeCompare(String(b.messageId || ''));
566
+ });
567
+ const messages = out.length > limit ? out.slice(-limit) : out;
568
+ peers.sort((a, b) => String(b.lastTime || '').localeCompare(String(a.lastTime || '')));
569
+ return {
570
+ messages,
571
+ peers,
572
+ count: messages.length,
573
+ total: out.length,
574
+ peerCount: peers.length,
575
+ unread: out.filter((m) => m.fresh).length,
576
+ };
577
+ }
578
+
579
+ // ---------------------------------------------------------------- 撤回自己的消息
580
+
581
+ /** 撤回自己发的一条消息(DingTalk 限制只能撤回自己发的) */
582
+ async function recallMessage(short, messageId) {
583
+ const mid = String(messageId || '').trim();
584
+ if (!mid) throw new PullError('缺少 messageId');
585
+
586
+ // 先在本工具里找到这条消息,确认是我发的,并拿到会话 ID
587
+ let cid = '';
588
+ let msg = null;
589
+ const candidates = [];
590
+ if (short && shortNames().includes(short)) candidates.push(short);
591
+ if (!short || archiveKind(short) === 'mentions') {
592
+ for (const s2 of shortNames()) if (!candidates.includes(s2)) candidates.push(s2);
593
+ }
594
+ for (const s2 of candidates) {
595
+ const list = loadMessages(s2).messages || [];
596
+ const hit = list.find((m) => String(m.messageId || '') === mid);
597
+ if (hit) {
598
+ msg = hit;
599
+ cid = String(hit.conversationId || conversationId(s2) || '');
600
+ break;
601
+ }
602
+ }
603
+ if (!msg) throw new PullError('归档里没有这条消息,先「拉取最新消息」再试');
604
+ const me = myOpenId();
605
+ if (!me) throw new PullError('拿不到自己的 openDingTalkId,无法确认归属');
606
+ if (String(msg.senderId || '') !== me) throw new PullError('只能撤回自己发的消息');
607
+ if (!cid) throw new PullError('这条消息缺少会话 ID');
608
+
609
+ runDws(['chat', 'message', 'recall', '--conversation-id', cid, '--message-id', mid, '--format', 'json'], {
610
+ timeout: 60000,
611
+ });
612
+
613
+ // 本地立刻打标记(不依赖钉钉回读)
614
+ markRecalledByMessageId(mid, '我');
615
+ // 顺手回读,让归档和钉钉一致
616
+ const owner = short && shortNames().includes(short) && archiveKind(short) !== 'mentions' ? short : null;
617
+ if (owner) {
618
+ try {
619
+ pullGroup(owner);
620
+ } catch {}
621
+ }
622
+ REV.bump('recall');
623
+ return { ok: true, messageId: mid, cid };
624
+ }
625
+
626
+ /** 把某条消息标成「已撤回」:清空正文,留撤回人和时间 */
627
+ function markRecalled(short, messageId, recaller = '') {
628
+ const mid = String(messageId || '').trim();
629
+ if (!mid || !shortNames().includes(short)) return { skipped: true };
630
+ const doc = loadMessages(short);
631
+ const list = doc.messages || [];
632
+ const hit = list.find((m) => String(m.messageId || '') === mid);
633
+ if (!hit) return { skipped: 'not-found' };
634
+ if (hit.recalled) return { duplicate: true };
635
+ hit.recalled = true;
636
+ hit.recalledAt = fmtSecond(new Date());
637
+ hit.recalledBy = String(recaller || '');
638
+ hit.text = '';
639
+ delete hit.quotedMessage;
640
+ writeArchive(short, doc, null);
641
+ REV.bump('recall-event');
642
+ return { ok: true, messageId: mid };
643
+ }
644
+
645
+ /** 按消息 ID 在所有归档里找并标记(撤回事件只知道 messageId) */
646
+ function markRecalledByMessageId(messageId, recaller = '') {
647
+ const mid = String(messageId || '').trim();
648
+ if (!mid) return { skipped: true };
649
+ for (const short of shortNames()) {
650
+ const list = loadMessages(short).messages || [];
651
+ if (list.some((m) => String(m.messageId || '') === mid)) {
652
+ return markRecalled(short, mid, recaller);
653
+ }
654
+ }
655
+ return { skipped: 'not-found' };
656
+ }
657
+
658
+ // ---------------------------------------------------------------- 发图片/文件
659
+
660
+ /**
661
+ * 发送剪贴板/拖进来的图片:CLI 只有「本地文件」通道(--msg-type file),
662
+ * 所以会作为**文件消息**发出去(钉钉里显示成可下载的附件,不是内联图片)。
663
+ */
664
+ /** 发送目标:优先用传入的 cid/openId,其次按归档推断 */
665
+ function resolveSendTarget({ short = '', cid = '', openId = '' } = {}) {
666
+ let targetCid = String(cid || '').trim();
667
+ let targetOpen = String(openId || '').trim();
668
+ if (!targetCid && !targetOpen && short && shortNames().includes(short)) {
669
+ if (archiveKind(short) === 'dm') {
670
+ const [oid] = dmPeer(short);
671
+ targetOpen = oid || '';
672
+ } else {
673
+ targetCid = conversationId(short) || '';
674
+ }
675
+ }
676
+ if (!targetCid && !targetOpen) throw new PullError('缺少会话目标(cid / openDingTalkId)');
677
+ return { targetCid, targetOpen };
678
+ }
679
+
680
+ /** dataURL → Buffer + 扩展名 */
681
+ function decodeDataUrl(dataUrl) {
682
+ const m = String(dataUrl).match(/^data:(image\/[a-z+]+);base64,([A-Za-z0-9+/=\s]+)$/i);
683
+ if (!m) throw new PullError('只支持图片(png/jpg/webp/gif)');
684
+ const mime = m[1].toLowerCase();
685
+ const ext = mime.includes('png') ? 'png'
686
+ : mime.includes('jpeg') || mime.includes('jpg') ? 'jpg'
687
+ : mime.includes('webp') ? 'webp' : 'gif';
688
+ const buf = Buffer.from(m[2].replace(/\s/g, ''), 'base64');
689
+ if (!buf.length) throw new PullError('图片内容为空');
690
+ return { buf, ext };
691
+ }
692
+
693
+ /**
694
+ * 图文混排发送:文字 + 一张或多张图片合成**一条** markdown 消息
695
+ * (钉钉只有公网 URL 的 markdown 图片才会内联渲染,所以图片要先传图床)
696
+ */
697
+ async function sendRich({ short = '', cid = '', openId = '', text = '', images = [], names = [] } = {}) {
698
+ const body = String(text || '').trim();
699
+ const list = Array.isArray(images) ? images.filter(Boolean) : [];
700
+ if (!body && !list.length) throw new PullError('没有内容可发');
701
+ const { targetCid, targetOpen } = resolveSendTarget({ short, cid, openId });
702
+
703
+ const dir = path.join(P.MEDIA_DIR, '已发');
704
+ ensureDir(dir);
705
+ const urls = [];
706
+ const saved = [];
707
+ for (let i = 0; i < list.length; i += 1) {
708
+ const { buf, ext } = decodeDataUrl(list[i]);
709
+ if (buf.length > 20 * 1024 * 1024) throw new PullError('图片太大(>20MB)');
710
+ const raw = String((names && names[i]) || `image-${i + 1}`).replace(/\.[^.]+$/, '');
711
+ const base = raw.replace(/[^A-Za-z0-9_\u4e00-\u9fa5-]/g, '').slice(0, 40) || 'image';
712
+ let file = `${base}.${ext}`;
713
+ for (let k = 2; fs.existsSync(path.join(dir, file)); k += 1) file = `${base}-${k}.${ext}`;
714
+ fs.writeFileSync(path.join(dir, file), buf);
715
+ saved.push(file);
716
+ urls.push(uploadImage(path.join(dir, file)));
717
+ }
718
+
719
+ const content = [body, ...urls.map((u) => `![](${u})`)].filter(Boolean).join('\n\n');
720
+ const args = ['chat', 'message', 'send'];
721
+ if (targetCid) args.push('--conversation-id', targetCid);
722
+ else args.push('--open-dingtalk-id', targetOpen);
723
+ args.push('--content', content);
724
+ if (body) args.push('--title', body.slice(0, 20));
725
+ args.push('--yes', '--format', 'json');
726
+ runDws(args, { timeout: 120000 });
727
+
728
+ if (short && shortNames().includes(short) && archiveKind(short) !== 'mentions') {
729
+ setTimeout(() => {
730
+ try {
731
+ pullGroup(short);
732
+ } catch {}
733
+ }, 1500).unref?.();
734
+ }
735
+ return { sent: true, text: body, images: urls.length, files: saved, content, target: targetCid || targetOpen };
736
+ }
737
+
738
+ const UPLOAD_SCRIPT = process.env.IMAGE_UPLOAD_SCRIPT
739
+ || '/Users/dds/.agents/skills/inke-image-upload/scripts/upload-image.mjs';
740
+
741
+ /** 把本地图片传到图床,拿公网 URL(钉钉 markdown 图片必须有公网地址才能内联渲染) */
742
+ function uploadImage(file) {
743
+ if (!fs.existsSync(UPLOAD_SCRIPT)) {
744
+ throw new PullError(`找不到图床上传脚本:${UPLOAD_SCRIPT}(可用 IMAGE_UPLOAD_SCRIPT 指定)`);
745
+ }
746
+ const r = spawnSync(process.execPath, [UPLOAD_SCRIPT, file], {
747
+ encoding: 'utf8',
748
+ timeout: 120000,
749
+ maxBuffer: 8 * 1024 * 1024,
750
+ });
751
+ const out = `${r.stdout || ''}`.trim();
752
+ if (r.status !== 0 || !out.includes('{')) {
753
+ throw new PullError(`图片上传失败:${((r.stderr || out) || '').slice(0, 300)}`);
754
+ }
755
+ let j;
756
+ try {
757
+ j = JSON.parse(out.slice(out.indexOf('{')));
758
+ } catch {
759
+ throw new PullError(`图片上传返回无法解析:${out.slice(0, 200)}`);
760
+ }
761
+ const url = String((j && (j.url || (j.data && j.data.url))) || '');
762
+ if (!url) throw new PullError(`图片上传没返回 URL:${out.slice(0, 200)}`);
763
+ return url;
764
+ }
765
+
766
+ /**
767
+ * 贴图发送:
768
+ * mode=inline(默认)→ 传到图床拿公网 URL,用 markdown 图片发出去(钉钉里是内联图片)
769
+ * mode=file → 走本地文件通道(作为附件)
770
+ */
771
+ async function sendImage({ short = '', cid = '', openId = '', dataUrl = '', name = '', mode = '' } = {}) {
772
+ const m = String(dataUrl).match(/^data:(image\/[a-z+]+);base64,([A-Za-z0-9+/=\s]+)$/i);
773
+ if (!m) throw new PullError('只支持图片(png/jpg/webp/gif)');
774
+ const ext = m[1].toLowerCase().includes('png') ? 'png'
775
+ : m[1].toLowerCase().includes('jpeg') || m[1].toLowerCase().includes('jpg') ? 'jpg'
776
+ : m[1].toLowerCase().includes('webp') ? 'webp' : 'gif';
777
+ const buf = Buffer.from(m[2].replace(/\s/g, ''), 'base64');
778
+ if (!buf.length) throw new PullError('图片内容为空');
779
+ if (buf.length > 20 * 1024 * 1024) throw new PullError('图片太大(>20MB)');
780
+
781
+ const { targetCid, targetOpen } = resolveSendTarget({ short, cid, openId });
782
+
783
+ // 存到 _媒体/已发/:钉钉不开放 fileId 下载,所以自己发的图要留本地副本才能回显
784
+ const dir = path.join(P.MEDIA_DIR, '已发');
785
+ ensureDir(dir);
786
+ const raw = String(name || '').replace(/\.[^.]+$/, '');
787
+ const base = (raw.replace(/[^A-Za-z0-9_\u4e00-\u9fa5-]/g, '').slice(0, 40) || 'image');
788
+ let file = `${base}.${ext}`;
789
+ for (let i = 2; fs.existsSync(path.join(dir, file)); i += 1) file = `${base}-${i}.${ext}`;
790
+ fs.writeFileSync(path.join(dir, file), buf);
791
+ try {
792
+ const idxFile = path.join(dir, '_index.json');
793
+ let idx = {};
794
+ try {
795
+ idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
796
+ } catch {}
797
+ idx[file] = { at: fmtSecond(new Date()), target: targetCid || targetOpen };
798
+ fs.writeFileSync(idxFile, `${JSON.stringify(idx, null, 2)}\n`, 'utf8');
799
+ } catch {}
800
+
801
+ const base1 = ['chat', 'message', 'send'];
802
+ if (targetCid) base1.push('--conversation-id', targetCid);
803
+ else base1.push('--open-dingtalk-id', targetOpen);
804
+
805
+ let mode1 = mode || SETTINGS.prefs().imageSendMode || 'inline';
806
+ let imageUrl = '';
807
+ if (mode1 !== 'file') {
808
+ try {
809
+ imageUrl = uploadImage(path.join(dir, file));
810
+ runDws([...base1, '--content', `![](${imageUrl})`, '--title', '图片', '--yes', '--format', 'json'], {
811
+ timeout: 120000,
812
+ });
813
+ } catch (e) {
814
+ // 图床不通就退回文件方式,别让用户白粘一次
815
+ mode1 = 'file';
816
+ runDws([...base1, '--msg-type', 'file', '--file', `./${file}`, '--yes', '--format', 'json'], {
817
+ cwd: dir,
818
+ timeout: 120000,
819
+ });
820
+ mode1 = `file(内联失败:${e.message.slice(0, 80)})`;
821
+ }
822
+ } else {
823
+ runDws([...base1, '--msg-type', 'file', '--file', `./${file}`, '--yes', '--format', 'json'], {
824
+ cwd: dir,
825
+ timeout: 120000,
826
+ });
827
+ }
828
+
829
+ // 让归档尽快跟上(失败不影响发送结论)
830
+ if (short && shortNames().includes(short) && archiveKind(short) !== 'mentions') {
831
+ setTimeout(() => {
832
+ try {
833
+ pullGroup(short);
834
+ } catch {}
835
+ }, 1500).unref?.();
836
+ }
837
+ return {
838
+ sent: true,
839
+ file,
840
+ imageUrl,
841
+ mode: mode1,
842
+ target: targetCid || targetOpen,
843
+ via: targetCid ? 'group' : 'dm',
844
+ };
845
+ }
846
+
348
847
  // ---------------------------------------------------------------- 打开钉钉
349
848
 
350
849
  function copyToClipboard(text) {
@@ -356,13 +855,32 @@ function copyToClipboard(text) {
356
855
  }
357
856
  }
358
857
 
359
- function openInDingtalk(short, messageId = '', copyOnly = false) {
360
- if (!shortNames().includes(short)) throw new PullError(`未登记该归档:${short}`);
361
- const doc = loadMessages(short);
362
- const cid = conversationId(short);
363
- const msgs = doc.messages || [];
364
- const msg = messageId ? msgs.find((m) => m.messageId === messageId) : null;
365
- const name = groupTitle(short);
858
+ /**
859
+ * 唤起钉钉 / 复制定位信息。
860
+ * - short 传了:按归档里的消息拼
861
+ * - 没传 short(@我 里来自未归档群的):用传入的 raw {cid,name,text,time,sender} 拼,一样能复制去钉钉里搜
862
+ */
863
+ function openInDingtalk(short, messageId = '', copyOnly = false, raw = null) {
864
+ const archived = Boolean(short) && shortNames().includes(short);
865
+ if (!archived && !raw) throw new PullError(`未登记该归档:${short}`);
866
+ let cid = '';
867
+ let name = '';
868
+ let msg = null;
869
+ if (archived) {
870
+ const doc = loadMessages(short);
871
+ cid = conversationId(short);
872
+ const msgs = doc.messages || [];
873
+ msg = messageId ? msgs.find((m) => m.messageId === messageId) : null;
874
+ name = groupTitle(short);
875
+ } else {
876
+ cid = String(raw.cid || '');
877
+ name = String(raw.name || '');
878
+ msg = {
879
+ createTime: String(raw.time || ''),
880
+ sender: String(raw.sender || ''),
881
+ text: String(raw.text || ''),
882
+ };
883
+ }
366
884
 
367
885
  const lines = [`群:${name}`];
368
886
  if (msg) {
@@ -434,6 +952,7 @@ function deleteArchive(short, purge = false) {
434
952
  }
435
953
 
436
954
  writeIndexMd();
955
+ REV.bump('delete');
437
956
  return { short, purged: purge, trash: trashed };
438
957
  }
439
958
 
@@ -446,7 +965,15 @@ module.exports = {
446
965
  mentionsSources,
447
966
  refreshMentions,
448
967
  dmPeer,
968
+ appendMessage,
969
+ markRecalled,
970
+ markRecalledByMessageId,
971
+ dmTimeline,
972
+ sendImage,
973
+ sendRich,
449
974
  sendMessage,
975
+ recallMessage,
976
+ sendToPerson,
450
977
  openInDingtalk,
451
978
  deleteArchive,
452
979
  };