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.
@@ -0,0 +1,563 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 常用联系人:**只针对真实单聊**(钉钉里的 direct 会话),不是群成员。
5
+ *
6
+ * 数据来源两条,合并去重(按单聊 openConversationId):
7
+ * 1. dws chat +recent-conversations → 当前账号真实的单聊会话(cid + 对方姓名 + 最近消息时间)
8
+ * 2. 本地已归档的单聊(_原始数据/<简称>_消息.json 里 kind=dm 的那批)
9
+ *
10
+ * 名单存在 <数据目录>/常用联系人.json:{ cid, name, openId?, addedAt }
11
+ * - 有 cid:点开就能归档 / 打开这个单聊,历史 + 发消息都走现成逻辑
12
+ * - 只有 openId(手动添加):没有会话 id,退化成「跨群时间线 + 直接按 openDingTalkId 发单聊」
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const { execFile } = require('node:child_process');
18
+
19
+ const P = require('./paths');
20
+ const { ensureDir, fmtSecond } = require('./util');
21
+ const {
22
+ shortNames,
23
+ loadMessages,
24
+ groupTitle,
25
+ archiveKind,
26
+ participants,
27
+ } = require('./archive');
28
+
29
+ const CONTACTS_FILE = path.join(P.DATA_DIR, '常用联系人.json');
30
+
31
+ // ---------------------------------------------------------------- 名单存取
32
+
33
+ function readContacts() {
34
+ try {
35
+ const raw = JSON.parse(fs.readFileSync(CONTACTS_FILE, 'utf8'));
36
+ const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.contacts) ? raw.contacts : [];
37
+ return list
38
+ .filter((c) => c && (c.cid || c.openId || c.openDingtalkId))
39
+ .map((c) => ({
40
+ cid: String(c.cid || c.conversationId || ''),
41
+ openId: String(c.openId || c.openDingtalkId || ''),
42
+ name: String(c.name || ''),
43
+ pinned: Boolean(c.pinned),
44
+ avatar: String(c.avatar || ''),
45
+ pinnedAt: String(c.pinnedAt || ''),
46
+ addedAt: String(c.addedAt || ''),
47
+ }));
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ function writeContacts(list) {
54
+ ensureDir(P.DATA_DIR);
55
+ fs.writeFileSync(
56
+ CONTACTS_FILE,
57
+ `${JSON.stringify({ version: 2, contacts: list }, null, 2)}\n`,
58
+ 'utf8'
59
+ );
60
+ }
61
+
62
+ const keyOf = (c) => c.cid || `open:${c.openId}`;
63
+
64
+ // ---------------------------------------------------------------- 单聊来源
65
+
66
+ /** 本地已归档的单聊:cid → { cid, openId, name, short, count, lastTime } */
67
+ function archivedDms() {
68
+ const out = new Map();
69
+ for (const short of shortNames()) {
70
+ if (archiveKind(short) !== 'dm') continue;
71
+ const doc = loadMessages(short);
72
+ const msgs = doc.messages || [];
73
+ const parts = participants(msgs);
74
+ const openId = String(doc.peerOpenId || (parts.length === 1 ? parts[0][0] : '') || '');
75
+ const cid = String(doc.conversationId || '');
76
+ const item = {
77
+ cid,
78
+ openId,
79
+ name: groupTitle(short) || (parts.length === 1 ? parts[0][1] : '') || short,
80
+ short,
81
+ count: msgs.length,
82
+ lastTime: msgs.length ? String(msgs[msgs.length - 1].createTime || '') : '',
83
+ preview: msgs.length ? previewOf(msgs[msgs.length - 1].text) : '',
84
+ };
85
+ if (cid) out.set(`cid:${cid}`, item);
86
+ if (openId) out.set(`open:${openId}`, item);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ const DM_CACHE_FILE = path.join(P.DATA_DIR, '_运行', 'dm-cache.json');
92
+ const DM_TTL = 30 * 60 * 1000; // 30 分钟
93
+
94
+ /**
95
+ * 钉钉单聊列表缓存。钉钉这个接口一页要十几秒,所以:
96
+ * - 本地永远读缓存(内存/磁盘),请求立刻返回
97
+ * - 缓存过期就起一个后台任务去刷新,不阻塞 HTTP
98
+ */
99
+ let LIVE = { list: [], at: 0, running: false, error: '' };
100
+
101
+ (function loadDiskCache() {
102
+ try {
103
+ const j = JSON.parse(fs.readFileSync(DM_CACHE_FILE, 'utf8'));
104
+ if (j && Array.isArray(j.list)) {
105
+ LIVE.list = j.list;
106
+ LIVE.at = Number(j.at) || 0;
107
+ }
108
+ } catch {}
109
+ })();
110
+
111
+ function saveDiskCache() {
112
+ try {
113
+ ensureDir(path.dirname(DM_CACHE_FILE));
114
+ fs.writeFileSync(DM_CACHE_FILE, `${JSON.stringify({ at: LIVE.at, list: LIVE.list }, null, 2)}\n`, 'utf8');
115
+ } catch {}
116
+ }
117
+
118
+ function runDwsAsync(args, { timeout = 180000 } = {}) {
119
+ return new Promise((resolve, reject) => {
120
+ execFile('dws', args, { timeout, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' }, (err, stdout) => {
121
+ if (err) return reject(err);
122
+ const out = String(stdout || '').trim();
123
+ if (!out.startsWith('{')) return reject(new Error('dws 返回不是 JSON'));
124
+ try {
125
+ resolve(JSON.parse(out));
126
+ } catch (e) {
127
+ reject(e);
128
+ }
129
+ });
130
+ });
131
+ }
132
+
133
+ async function fetchLiveDms() {
134
+ const now = new Date();
135
+ const start = new Date(now.getTime() - 365 * 86400000);
136
+ const data = await runDwsAsync([
137
+ 'chat', '+recent-conversations',
138
+ '--start', fmtSecond(start), '--end', fmtSecond(now),
139
+ '--limit', '100', '--format', 'json',
140
+ ]);
141
+ const list = (data && data.data && data.data.conversations) || (data && data.conversations) || [];
142
+ return list
143
+ .filter((c) => String(c.type || '') === 'direct' && c.conversationId)
144
+ .map((c) => ({
145
+ cid: String(c.conversationId),
146
+ name: String(c.name || '(未命名单聊)'),
147
+ lastTime: String(c.latestMessageTime || '').replace('T', ' ').slice(0, 16),
148
+ }));
149
+ }
150
+
151
+ // ---------------------------------------------------------------- 最后一条消息预览
152
+
153
+ const PREVIEW_FILE = path.join(P.DATA_DIR, '_运行', 'dm-preview.json');
154
+ const PREVIEW_TTL = 10 * 60 * 1000;
155
+
156
+ /** cid -> { text, time, at } */
157
+ let PREVIEWS = {};
158
+ let PREVIEW_RUNNING = false;
159
+
160
+ (function loadPreviewCache() {
161
+ try {
162
+ const j = JSON.parse(fs.readFileSync(PREVIEW_FILE, 'utf8'));
163
+ if (j && typeof j === 'object') PREVIEWS = j;
164
+ } catch {}
165
+ })();
166
+
167
+ function savePreviewCache() {
168
+ try {
169
+ ensureDir(path.dirname(PREVIEW_FILE));
170
+ fs.writeFileSync(PREVIEW_FILE, `${JSON.stringify(PREVIEWS, null, 2)}\n`, 'utf8');
171
+ } catch {}
172
+ }
173
+
174
+ /** 把消息正文压成一行预览 */
175
+ function previewOf(text) {
176
+ const t = String(text || '')
177
+ .replace(/\r/g, '')
178
+ .replace(/\n+/g, ' ')
179
+ .replace(/\[文件\]/g, '[文件]')
180
+ .trim();
181
+ return t.length > 60 ? `${t.slice(0, 60)}…` : t;
182
+ }
183
+
184
+ async function fetchLastMessage(cid) {
185
+ const data = await runDwsAsync(
186
+ ['chat', '+chat-messages', '--group', cid, '--order', 'desc', '--limit', '1',
187
+ '--no-reactions', '--format', 'json'],
188
+ { timeout: 60000 }
189
+ );
190
+ const msgs = data.messages || [];
191
+ const m = msgs[0];
192
+ if (!m) return null;
193
+ return { text: previewOf(m.text), time: String(m.createTime || '').slice(0, 16), at: Date.now() };
194
+ }
195
+
196
+ /** 后台补齐常用联系人的「最后一条消息」,不阻塞接口 */
197
+ function pumpPreviews() {
198
+ if (PREVIEW_RUNNING) return;
199
+ const pool = dmPool(false);
200
+ const need = readContacts()
201
+ .filter((c) => c.cid)
202
+ .map((c) => {
203
+ const hit = pool.get(`cid:${c.cid}`);
204
+ const cached = PREVIEWS[c.cid];
205
+ const fresh = cached && Date.now() - Number(cached.at || 0) < PREVIEW_TTL;
206
+ return { cid: c.cid, archived: Boolean(hit && hit.short), fresh };
207
+ })
208
+ .filter((x) => !x.fresh);
209
+ if (!need.length) return;
210
+ PREVIEW_RUNNING = true;
211
+ (async () => {
212
+ try {
213
+ for (const item of need) {
214
+ try {
215
+ const got = await fetchLastMessage(item.cid);
216
+ if (got) PREVIEWS[item.cid] = got;
217
+ else PREVIEWS[item.cid] = { text: '', time: '', at: Date.now() };
218
+ } catch {
219
+ PREVIEWS[item.cid] = { text: '', time: '', at: Date.now(), error: true };
220
+ }
221
+ }
222
+ savePreviewCache();
223
+ } finally {
224
+ PREVIEW_RUNNING = false;
225
+ }
226
+ })();
227
+ }
228
+
229
+ /** 拿缓存里的单聊列表;过期就后台刷新,返回当前状态(绝不阻塞) */
230
+ function liveDms(force = false) {
231
+ const stale = Date.now() - LIVE.at > DM_TTL;
232
+ if ((stale || force) && !LIVE.running) {
233
+ LIVE.running = true;
234
+ LIVE.error = '';
235
+ fetchLiveDms()
236
+ .then((list) => {
237
+ LIVE = { list, at: Date.now(), running: false, error: '' };
238
+ saveDiskCache();
239
+ })
240
+ .catch((e) => {
241
+ LIVE.running = false;
242
+ LIVE.error = e.message || String(e);
243
+ });
244
+ }
245
+ return {
246
+ list: LIVE.list,
247
+ updatedAt: LIVE.at,
248
+ refreshing: LIVE.running,
249
+ error: LIVE.error,
250
+ };
251
+ }
252
+
253
+ /** 单聊候选池:真实单聊 + 已归档单聊,按 cid 去重,已归档的带上条数 */
254
+ function dmPool(includeLive = true) {
255
+ const arch = archivedDms();
256
+ const map = new Map(); // key -> { cid, openId, name, short, count, lastTime, archived }
257
+ for (const item of arch.values()) {
258
+ const key = item.cid ? `cid:${item.cid}` : `open:${item.openId}`;
259
+ map.set(key, { ...item, archived: true });
260
+ }
261
+ if (!includeLive) return map;
262
+ for (const conv of liveDms().list) {
263
+ const key = `cid:${conv.cid}`;
264
+ const hit = map.get(key);
265
+ if (hit) {
266
+ if (!hit.name || hit.name === conv.name) hit.name = conv.name || hit.name;
267
+ if (!hit.lastTime) hit.lastTime = conv.lastTime;
268
+ } else {
269
+ map.set(key, { cid: conv.cid, openId: '', name: conv.name, short: '', count: 0, lastTime: conv.lastTime, archived: false });
270
+ }
271
+ }
272
+ return map;
273
+ }
274
+
275
+ // ---------------------------------------------------------------- 对外接口
276
+
277
+ /** 常用联系人(带单聊状态:是否已归档、多少条、最近时间) */
278
+ function listContacts() {
279
+ const pool = dmPool(false); // 侧栏只看本地归档,不碰网络
280
+ pumpPreviews(); // 缺预览的在后台慢慢补
281
+ const rows = readContacts().map((c) => {
282
+ const hit = (c.cid && pool.get(`cid:${c.cid}`)) || (c.openId && pool.get(`open:${c.openId}`)) || null;
283
+ const pv = c.cid ? PREVIEWS[c.cid] : null;
284
+ const archTime = (hit && hit.lastTime) || '';
285
+ // 归档里的最后一条 / 实时拉到的最后一条,取更新的那个
286
+ const useLive = pv && pv.text && String(pv.time || '') >= archTime;
287
+ return {
288
+ ...c,
289
+ name: c.name || (hit && hit.name) || c.cid || c.openId,
290
+ openId: c.openId || (hit && hit.openId) || '',
291
+ hasDm: Boolean(hit && hit.cid),
292
+ short: hit ? hit.short : '',
293
+ msgCount: hit ? hit.count : 0,
294
+ lastTime: useLive ? String(pv.time || '') : archTime,
295
+ preview: useLive ? String(pv.text || '') : (hit ? String(hit.preview || '') : ''),
296
+ previewPending: !useLive && !(hit && hit.preview) && Boolean(c.cid),
297
+ };
298
+ });
299
+
300
+ return rows; // 顺序 = 名单里的顺序(可拖动调整;置顶只是把条目前移 + 标记)
301
+ }
302
+
303
+ /** 可选单聊池(只列真实单聊 / 已归档单聊,不列群成员) */
304
+ function candidates(query = '', force = false) {
305
+ const q = String(query || '').trim().toLowerCase();
306
+ const saved = new Set(readContacts().map(keyOf));
307
+ let rows = [...dmPool().values()].map((p) => ({
308
+ cid: p.cid,
309
+ openId: p.openId,
310
+ name: p.name,
311
+ msgCount: p.count,
312
+ lastTime: p.lastTime,
313
+ short: p.short,
314
+ archived: p.archived,
315
+ saved: saved.has(p.cid || `open:${p.openId}`),
316
+ }));
317
+ if (q) {
318
+ rows = rows.filter(
319
+ (r) =>
320
+ r.name.toLowerCase().includes(q) ||
321
+ String(r.cid || '').toLowerCase().includes(q) ||
322
+ String(r.openId || '').toLowerCase().includes(q)
323
+ );
324
+ }
325
+ rows.sort((a, b) => String(b.lastTime || '').localeCompare(String(a.lastTime || '')));
326
+ const live = liveDms(Boolean(force));
327
+ return {
328
+ total: rows.length,
329
+ candidates: rows.slice(0, 300),
330
+ dmCache: {
331
+ updatedAt: live.updatedAt,
332
+ refreshing: live.refreshing,
333
+ error: live.error,
334
+ count: live.list.length,
335
+ },
336
+ };
337
+ }
338
+
339
+ function addContact({ cid, openId, name }) {
340
+ const theCid = String(cid || '').trim();
341
+ const theOpen = String(openId || '').trim();
342
+ const nm = String(name || '').trim();
343
+ if (!theCid && !theOpen) {
344
+ const err = new Error('需要一个单聊会话 ID(openConversationId)或 openDingTalkId');
345
+ err.status = 400;
346
+ throw err;
347
+ }
348
+ const list = readContacts();
349
+ const hit = list.find((c) => (theCid ? c.cid === theCid : c.openId === theOpen));
350
+ if (hit) {
351
+ if (nm) hit.name = nm;
352
+ if (theCid) hit.cid = theCid;
353
+ if (theOpen) hit.openId = theOpen;
354
+ } else {
355
+ list.unshift({ cid: theCid, openId: theOpen, name: nm, addedAt: new Date().toISOString() });
356
+ }
357
+ writeContacts(list);
358
+ return { ok: true, total: list.length, contact: list.find((c) => (theCid ? c.cid === theCid : c.openId === theOpen)) };
359
+ }
360
+
361
+ function pinContact({ cid, openId, pinned = true }) {
362
+ const theCid = String(cid || '').trim();
363
+ const theOpen = String(openId || '').trim();
364
+ if (!theCid && !theOpen) {
365
+ const err = new Error('缺少 cid 或 openDingTalkId');
366
+ err.status = 400;
367
+ throw err;
368
+ }
369
+ const list = readContacts();
370
+ const hit = list.find((c) => (theCid ? c.cid === theCid : c.openId === theOpen));
371
+ if (!hit) {
372
+ const err = new Error('这条不在常用联系人里');
373
+ err.status = 404;
374
+ throw err;
375
+ }
376
+ hit.pinned = Boolean(pinned);
377
+ hit.pinnedAt = hit.pinned ? new Date().toISOString() : '';
378
+ writeContacts(list);
379
+ return { ok: true, contact: hit };
380
+ }
381
+
382
+ /** 拖动排序:按传入的 key 顺序重排名单(key = cid|open:<openId>) */
383
+ function reorderContacts(keys = []) {
384
+ const list = readContacts();
385
+ const keyOfRow = (c) => c.cid || `open:${c.openId}`;
386
+ const map = new Map(list.map((c) => [keyOfRow(c), c]));
387
+ const next = [];
388
+ for (const k of keys) {
389
+ const hit = map.get(String(k));
390
+ if (hit) {
391
+ next.push(hit);
392
+ map.delete(String(k));
393
+ }
394
+ }
395
+ for (const rest of map.values()) next.push(rest); // 没传到的保持在后
396
+ writeContacts(next);
397
+ return { ok: true, total: next.length, order: next.map(keyOfRow) };
398
+ }
399
+
400
+ const AVATAR_DIR = path.join(P.MEDIA_DIR, '头像');
401
+ const AVATAR_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
402
+
403
+ /** 直接存一个图片链接(http/https),前端按 <img src> 显示 */
404
+ function setAvatarUrl({ cid, openId, url = '' }) {
405
+ const theCid = String(cid || '').trim();
406
+ const theOpen = String(openId || '').trim();
407
+ const u = String(url || '').trim();
408
+ if (!/^https?:\/\/[^\s]+$/i.test(u) && !/^data:image\//i.test(u)) {
409
+ const err = new Error('只支持 http(s) 图片链接');
410
+ err.status = 400;
411
+ throw err;
412
+ }
413
+ const list = readContacts();
414
+ const hit = list.find((c) => (theCid ? c.cid === theCid : c.openId === theOpen));
415
+ if (!hit) {
416
+ const err = new Error('这条不在常用联系人里');
417
+ err.status = 404;
418
+ throw err;
419
+ }
420
+ hit.avatar = u;
421
+ writeContacts(list);
422
+ return { ok: true, contact: hit };
423
+ }
424
+
425
+ /** 自定义头像:前端传 dataURL,落盘到 _媒体/头像/,联系人里只存 /api/avatar/<文件> */
426
+ function setAvatar({ cid, openId, dataUrl = '', clear = false }) {
427
+ const theCid = String(cid || '').trim();
428
+ const theOpen = String(openId || '').trim();
429
+ if (!theCid && !theOpen) {
430
+ const err = new Error('缺少 cid 或 openDingTalkId');
431
+ err.status = 400;
432
+ throw err;
433
+ }
434
+ const list = readContacts();
435
+ const hit = list.find((c) => (theCid ? c.cid === theCid : c.openId === theOpen));
436
+ if (!hit) {
437
+ const err = new Error('这条不在常用联系人里');
438
+ err.status = 404;
439
+ throw err;
440
+ }
441
+ if (clear) {
442
+ hit.avatar = '';
443
+ writeContacts(list);
444
+ return { ok: true, contact: hit };
445
+ }
446
+ const m = String(dataUrl).match(/^data:(image\/[a-z+]+);base64,([A-Za-z0-9+/=\s]+)$/i);
447
+ if (!m) {
448
+ const err = new Error('只支持图片 dataURL(png/jpeg/webp/gif)');
449
+ err.status = 400;
450
+ throw err;
451
+ }
452
+ const ext = AVATAR_EXT[m[1].toLowerCase()];
453
+ if (!ext) {
454
+ const err = new Error('不支持的图片类型:' + m[1]);
455
+ err.status = 400;
456
+ throw err;
457
+ }
458
+ const buf = Buffer.from(m[2].replace(/\s/g, ''), 'base64');
459
+ if (!buf.length) {
460
+ const err = new Error('图片内容为空');
461
+ err.status = 400;
462
+ throw err;
463
+ }
464
+ if (buf.length > 3 * 1024 * 1024) {
465
+ const err = new Error('头像太大了(>3MB),先压缩一下');
466
+ err.status = 400;
467
+ throw err;
468
+ }
469
+ ensureDir(AVATAR_DIR);
470
+ const safe = (theCid || theOpen).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32) || 'contact';
471
+ const file = `${safe}-${Date.now().toString(36)}.${ext}`;
472
+ // 先清掉这个人的旧头像(别把刚写的文件也删了)
473
+ for (const old of fs.readdirSync(AVATAR_DIR)) {
474
+ if (old !== file && old.startsWith(`${safe}-`)) {
475
+ try {
476
+ fs.unlinkSync(path.join(AVATAR_DIR, old));
477
+ } catch {}
478
+ }
479
+ }
480
+ fs.writeFileSync(path.join(AVATAR_DIR, file), buf);
481
+ hit.avatar = `/api/avatar/${file}`;
482
+ writeContacts(list);
483
+ return { ok: true, contact: hit };
484
+ }
485
+
486
+ /** 读头像文件(防路径穿越) */
487
+ function avatarFile(name) {
488
+ const base = path.basename(String(name || ''));
489
+ if (!/^[A-Za-z0-9_-]+\.(png|jpg|jpeg|webp|gif)$/i.test(base)) return '';
490
+ const file = path.join(AVATAR_DIR, base);
491
+ return fs.existsSync(file) ? file : '';
492
+ }
493
+
494
+ function removeContact({ cid, openId }) {
495
+ const theCid = String(cid || '').trim();
496
+ const theOpen = String(openId || '').trim();
497
+ const list = readContacts();
498
+ const next = list.filter((c) => (theCid ? c.cid !== theCid : c.openId !== theOpen));
499
+ writeContacts(next);
500
+ return { ok: true, removed: list.length - next.length, total: next.length };
501
+ }
502
+
503
+ /** 只有 openId、没有单聊会话的手动联系人:退化成跨群时间线 */
504
+ function personMessages(openId) {
505
+ const oid = String(openId || '').trim();
506
+ if (!oid) {
507
+ const err = new Error('缺少 openDingTalkId');
508
+ err.status = 400;
509
+ throw err;
510
+ }
511
+ const messages = [];
512
+ const counts = new Map();
513
+ let name = '';
514
+ for (const short of shortNames()) {
515
+ if (archiveKind(short) === 'mentions') continue;
516
+ for (const m of loadMessages(short).messages || []) {
517
+ if (String(m.senderId || '') !== oid) continue;
518
+ if (!name && m.sender) name = m.sender;
519
+ messages.push({
520
+ conversationId: m.conversationId || '',
521
+ messageId: m.messageId || '',
522
+ sender: m.sender || '',
523
+ senderId: m.senderId || '',
524
+ text: m.text || '',
525
+ createTime: m.createTime || '',
526
+ groupShort: short,
527
+ groupName: groupTitle(short),
528
+ });
529
+ counts.set(short, (counts.get(short) || 0) + 1);
530
+ }
531
+ }
532
+ messages.sort((a, b) => {
533
+ const t = String(a.createTime || '').localeCompare(String(b.createTime || ''));
534
+ return t !== 0 ? t : String(a.messageId || '').localeCompare(String(b.messageId || ''));
535
+ });
536
+ const groups = [...counts.entries()]
537
+ .map(([short, count]) => ({ short, name: groupTitle(short), count }))
538
+ .sort((a, b) => b.count - a.count);
539
+ const saved = readContacts().find((c) => c.openId === oid);
540
+ return {
541
+ openId: oid,
542
+ name: (saved && saved.name) || name || oid,
543
+ messages,
544
+ groups,
545
+ count: messages.length,
546
+ };
547
+ }
548
+
549
+ module.exports = {
550
+ CONTACTS_FILE,
551
+ readContacts,
552
+ listContacts,
553
+ addContact,
554
+ removeContact,
555
+ pinContact,
556
+ reorderContacts,
557
+ setAvatar,
558
+ setAvatarUrl,
559
+ avatarFile,
560
+ candidates,
561
+ personMessages,
562
+ dmPool,
563
+ };
package/src/layout.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 左侧会话列表的布局:手拖顺序 + 置顶。
5
+ * 存 <数据目录>/布局.json —— {"order": ["群简称", ...], "pinned": ["群简称", ...]}
6
+ * 只记「简称」,群删了/改了名会自动从列表里消失(排序时忽略未知项)。
7
+ */
8
+
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+
12
+ const P = require('./paths');
13
+ const { ensureDir } = require('./util');
14
+
15
+ const LAYOUT_FILE = path.join(P.DATA_DIR, '布局.json');
16
+
17
+ function readLayout() {
18
+ try {
19
+ const j = JSON.parse(fs.readFileSync(LAYOUT_FILE, 'utf8'));
20
+ const arr = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []);
21
+ return { order: arr(j.order), pinned: arr(j.pinned) };
22
+ } catch {
23
+ return { order: [], pinned: [] };
24
+ }
25
+ }
26
+
27
+ function writeLayout(layout) {
28
+ ensureDir(P.DATA_DIR);
29
+ const out = {
30
+ order: Array.isArray(layout.order) ? [...new Set(layout.order.filter(Boolean))] : [],
31
+ pinned: Array.isArray(layout.pinned) ? [...new Set(layout.pinned.filter(Boolean))] : [],
32
+ };
33
+ fs.writeFileSync(LAYOUT_FILE, `${JSON.stringify(out, null, 2)}\n`, 'utf8');
34
+ return out;
35
+ }
36
+
37
+ /** 拖动排序:keys = 拖完之后的完整可视顺序(会话简称数组) */
38
+ function reorderGroups({ keys = [] } = {}) {
39
+ const layout = readLayout();
40
+ const incoming = (Array.isArray(keys) ? keys : []).filter((k) => typeof k === 'string' && k);
41
+ const known = new Set(incoming);
42
+ // 没出现在可视列表里的(例如被筛选掉了)按原顺序接在后面,别丢
43
+ const tail = layout.order.filter((k) => !known.has(k));
44
+ layout.order = [...incoming, ...tail];
45
+ return { ok: true, layout: writeLayout(layout) };
46
+ }
47
+
48
+ function setPinned({ short, pinned = true } = {}) {
49
+ const name = String(short || '').trim();
50
+ if (!name) {
51
+ const err = new Error('缺少会话简称');
52
+ err.status = 400;
53
+ throw err;
54
+ }
55
+ const layout = readLayout();
56
+ const set = new Set(layout.pinned);
57
+ if (pinned) set.add(name);
58
+ else set.delete(name);
59
+ layout.pinned = [...set];
60
+ return { ok: true, layout: writeLayout(layout) };
61
+ }
62
+
63
+ module.exports = { LAYOUT_FILE, readLayout, writeLayout, reorderGroups, setPinned };