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/README.md +17 -5
- package/bin/oh-my-dws.js +2 -2
- package/docs//344/275/277/347/224/250/350/257/264/346/230/216.md +104 -5
- package/package.json +1 -1
- package/src/actions.js +590 -63
- package/src/ai.js +300 -0
- package/src/archive.js +17 -8
- package/src/auth.js +33 -1
- package/src/avatars.js +143 -0
- package/src/contacts.js +563 -0
- package/src/layout.js +63 -0
- package/src/listen.js +289 -0
- package/src/media.js +12 -1
- package/src/rev.js +23 -0
- package/src/server.js +385 -7
- package/src/settings.js +51 -0
- package/src/sync.js +213 -0
- package/web/index.html +2136 -74
package/src/ai.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI 帮忙写回复:任意 OpenAI 兼容接口(/chat/completions)。
|
|
5
|
+
*
|
|
6
|
+
* 配置来源,按优先级:
|
|
7
|
+
* 1. <数据目录>/ai.json(界面上填的,最优先)
|
|
8
|
+
* 2. 环境变量:AI_BASE_URL / AI_API_KEY / AI_MODEL(也认 OPENAI_*)
|
|
9
|
+
* 3. pi 自己的 ~/.pi/agent/models.json + settings.json 的默认 provider/model
|
|
10
|
+
* —— 这样在本机通常「零配置」就能用
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const P = require('./paths');
|
|
18
|
+
const { ensureDir } = require('./util');
|
|
19
|
+
|
|
20
|
+
const CONFIG_FILE = path.join(P.DATA_DIR, 'ai.json');
|
|
21
|
+
|
|
22
|
+
function readFileConfig() {
|
|
23
|
+
try {
|
|
24
|
+
const j = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
25
|
+
return {
|
|
26
|
+
baseUrl: String(j.baseUrl || '').trim(),
|
|
27
|
+
apiKey: String(j.apiKey || '').trim(),
|
|
28
|
+
model: String(j.model || '').trim(),
|
|
29
|
+
systemPrompt: String(j.systemPrompt || '').trim(),
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 从 pi 的配置里推断一个可用端点(只读,不改它) */
|
|
37
|
+
function fromPi() {
|
|
38
|
+
try {
|
|
39
|
+
const home = os.homedir();
|
|
40
|
+
const models = JSON.parse(fs.readFileSync(path.join(home, '.pi/agent/models.json'), 'utf8'));
|
|
41
|
+
const settings = JSON.parse(fs.readFileSync(path.join(home, '.pi/agent/settings.json'), 'utf8'));
|
|
42
|
+
const providers = models.providers || {};
|
|
43
|
+
const wanted = [settings.defaultProvider, ...Object.keys(providers)].filter(Boolean);
|
|
44
|
+
for (const name of wanted) {
|
|
45
|
+
const p = providers[name];
|
|
46
|
+
if (!p || !p.baseUrl || !p.apiKey) continue;
|
|
47
|
+
if (p.api && !String(p.api).includes('openai')) continue;
|
|
48
|
+
const list = Array.isArray(p.models) ? p.models : [];
|
|
49
|
+
const preferred = settings.defaultProvider === name && settings.defaultModel
|
|
50
|
+
? settings.defaultModel
|
|
51
|
+
: list[0] && list[0].id;
|
|
52
|
+
if (!preferred) continue;
|
|
53
|
+
return {
|
|
54
|
+
baseUrl: String(p.baseUrl).replace(/\/+$/, ''),
|
|
55
|
+
apiKey: String(p.apiKey),
|
|
56
|
+
model: String(preferred),
|
|
57
|
+
source: `pi:${name}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
/* pi 没装/格式变了就算了 */
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveConfig() {
|
|
67
|
+
const file = readFileConfig();
|
|
68
|
+
if (file && file.baseUrl && file.apiKey && file.model) return { ...file, source: 'ai.json' };
|
|
69
|
+
|
|
70
|
+
const env = {
|
|
71
|
+
baseUrl: String(process.env.AI_BASE_URL || process.env.OPENAI_BASE_URL || '').trim(),
|
|
72
|
+
apiKey: String(process.env.AI_API_KEY || process.env.OPENAI_API_KEY || '').trim(),
|
|
73
|
+
model: String(process.env.AI_MODEL || process.env.OPENAI_MODEL || '').trim(),
|
|
74
|
+
};
|
|
75
|
+
if (env.baseUrl && env.apiKey && env.model) return { ...env, source: 'env' };
|
|
76
|
+
|
|
77
|
+
const pi = fromPi();
|
|
78
|
+
if (pi) return pi;
|
|
79
|
+
|
|
80
|
+
return { baseUrl: '', apiKey: '', model: '', source: '', systemPrompt: '' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 给前端看的配置(apiKey 只回掩码) */
|
|
84
|
+
function publicConfig() {
|
|
85
|
+
const c = resolveConfig();
|
|
86
|
+
const usable = Boolean(c.baseUrl && c.apiKey && c.model);
|
|
87
|
+
return {
|
|
88
|
+
configured: usable,
|
|
89
|
+
source: c.source || '',
|
|
90
|
+
baseUrl: c.baseUrl || '',
|
|
91
|
+
model: c.model || '',
|
|
92
|
+
apiKeyMasked: c.apiKey ? `${c.apiKey.slice(0, 4)}…${c.apiKey.slice(-4)}` : '',
|
|
93
|
+
systemPrompt: c.systemPrompt || '',
|
|
94
|
+
canSave: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function clearConfig() {
|
|
99
|
+
try {
|
|
100
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
101
|
+
} catch {}
|
|
102
|
+
return publicConfig();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function saveConfig({ baseUrl, apiKey, model, systemPrompt }) {
|
|
106
|
+
const cur = readFileConfig() || {};
|
|
107
|
+
const next = {
|
|
108
|
+
baseUrl: String(baseUrl || cur.baseUrl || '').trim().replace(/\/+$/, ''),
|
|
109
|
+
apiKey: String(apiKey || cur.apiKey || '').trim(),
|
|
110
|
+
model: String(model || cur.model || '').trim(),
|
|
111
|
+
systemPrompt: String(systemPrompt == null ? cur.systemPrompt || '' : systemPrompt).trim(),
|
|
112
|
+
};
|
|
113
|
+
ensureDir(P.DATA_DIR);
|
|
114
|
+
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
115
|
+
return publicConfig();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const DEFAULT_SYSTEM = `你在帮用户起草钉钉工作消息的中文回复。
|
|
119
|
+
要求:
|
|
120
|
+
- 只输出消息正文,不要解释、不要加引号、不要用 markdown 标题或列表符号(必要时可用 1. 2. 3. 这种行内编号)
|
|
121
|
+
- 语气跟上下文一致:工作群里直接、简洁、有结论;先给结论或结论性表态,再补一两句必要信息
|
|
122
|
+
- 不编造事实:不知道的信息不要瞎写,可以用「我确认下」「稍后回复」这类说法
|
|
123
|
+
- 长度控制在 1~3 句,除非用户明确要求写长
|
|
124
|
+
- 不要带署名、不要写「收到,感谢」这类空话堆砌`;
|
|
125
|
+
|
|
126
|
+
function trimText(s, n) {
|
|
127
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
128
|
+
return t.length > n ? `${t.slice(0, n)}…` : t;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function draftReply({ context = [], target = '', hint = '', me = '' } = {}) {
|
|
132
|
+
const cfg = resolveConfig();
|
|
133
|
+
if (!cfg.baseUrl || !cfg.apiKey || !cfg.model) {
|
|
134
|
+
const err = new Error('还没配置 AI:在发消息浮层点「✨ AI」填一下接口地址、模型和 Key(也可以设置 AI_BASE_URL / AI_API_KEY / AI_MODEL)');
|
|
135
|
+
err.status = 400;
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const lines = (Array.isArray(context) ? context : [])
|
|
140
|
+
.slice(-40)
|
|
141
|
+
.map((m) => `${m.sender || '?'}(${String(m.time || '').slice(5, 16)}):${trimText(m.text, 600)}`)
|
|
142
|
+
.join('\n');
|
|
143
|
+
|
|
144
|
+
const userMsg = [
|
|
145
|
+
target ? `# 会话\n${target}` : '',
|
|
146
|
+
me ? `# 我是\n${me}` : '',
|
|
147
|
+
lines ? `# 最近的对话(从旧到新)\n${lines}` : '',
|
|
148
|
+
hint ? `# 我想表达的意思 / 补充要求\n${hint}` : '',
|
|
149
|
+
'# 任务\n请以「我」的身份写一条要发到这个会话里的回复。',
|
|
150
|
+
].filter(Boolean).join('\n\n');
|
|
151
|
+
|
|
152
|
+
const url = `${cfg.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
153
|
+
const ac = new AbortController();
|
|
154
|
+
const timer = setTimeout(() => ac.abort(), 60000);
|
|
155
|
+
let res;
|
|
156
|
+
try {
|
|
157
|
+
res = await fetch(url, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
signal: ac.signal,
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
model: cfg.model,
|
|
166
|
+
messages: [
|
|
167
|
+
{ role: 'system', content: cfg.systemPrompt || DEFAULT_SYSTEM },
|
|
168
|
+
{ role: 'user', content: userMsg },
|
|
169
|
+
],
|
|
170
|
+
temperature: 0.6,
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
} catch (e) {
|
|
174
|
+
const err = new Error(`连不上 ${url}:${e.name === 'AbortError' ? '超时(60s)' : e.message}`);
|
|
175
|
+
err.status = 502;
|
|
176
|
+
throw err;
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const raw = await res.text();
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
const err = new Error(`AI 接口返回 ${res.status}:${trimText(raw, 300)}`);
|
|
184
|
+
err.status = 502;
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
let data;
|
|
188
|
+
try {
|
|
189
|
+
data = JSON.parse(raw);
|
|
190
|
+
} catch {
|
|
191
|
+
const err = new Error('AI 接口返回的不是 JSON');
|
|
192
|
+
err.status = 502;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
const text = String(
|
|
196
|
+
(data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || ''
|
|
197
|
+
).trim();
|
|
198
|
+
if (!text) {
|
|
199
|
+
const err = new Error('AI 没返回内容');
|
|
200
|
+
err.status = 502;
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
return { text, model: cfg.model, source: cfg.source };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SUMMARY_SYSTEM = `你在帮用户快速读懂一段钉钉工作群/单聊记录。
|
|
207
|
+
要求:
|
|
208
|
+
- 用中文,纯文本,允许用「1. 2. 3.」或「- 」开头的短行,不要 markdown 标题符号(#)
|
|
209
|
+
- 结构固定三段,没有内容就写「无」:
|
|
210
|
+
【结论】一两句话说明这段对话在聊什么、结果是什么
|
|
211
|
+
【待办】谁需要做什么,没有就写「无」
|
|
212
|
+
【风险/分歧】没定的事、有争议的点,没有就写「无」
|
|
213
|
+
- 只根据给定消息,不补充任何外部信息,不猜测人名或时间
|
|
214
|
+
- 总长度控制在 200 字左右`;
|
|
215
|
+
|
|
216
|
+
async function summarize({ messages = [], target = '', rangeLabel = '' } = {}) {
|
|
217
|
+
const cfg = resolveConfig();
|
|
218
|
+
if (!cfg.baseUrl || !cfg.apiKey || !cfg.model) {
|
|
219
|
+
const err = new Error('还没配置 AI:点右上账号胶囊里的「AI 设置」填一下接口地址、模型和 Key');
|
|
220
|
+
err.status = 400;
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
const rows = (Array.isArray(messages) ? messages : []).slice(-300);
|
|
224
|
+
if (!rows.length) {
|
|
225
|
+
const err = new Error('当前范围没有消息可总结');
|
|
226
|
+
err.status = 400;
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
const text = rows
|
|
230
|
+
.map((m) => `${m.sender || '?'}(${String(m.time || '').slice(5, 16)}):${trimText(m.text, 400)}`)
|
|
231
|
+
.join('\n');
|
|
232
|
+
|
|
233
|
+
const userMsg = [
|
|
234
|
+
target ? `# 会话\n${target}` : '',
|
|
235
|
+
rangeLabel ? `# 范围\n${rangeLabel},共 ${rows.length} 条` : `# 范围\n共 ${rows.length} 条`,
|
|
236
|
+
`# 消息(从旧到新)\n${text}`,
|
|
237
|
+
'# 任务\n请按要求总结这段对话。',
|
|
238
|
+
].filter(Boolean).join('\n\n');
|
|
239
|
+
|
|
240
|
+
const url = `${cfg.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
241
|
+
const ac = new AbortController();
|
|
242
|
+
const timer = setTimeout(() => ac.abort(), 90000);
|
|
243
|
+
let res;
|
|
244
|
+
try {
|
|
245
|
+
res = await fetch(url, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
signal: ac.signal,
|
|
248
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.apiKey}` },
|
|
249
|
+
body: JSON.stringify({
|
|
250
|
+
model: cfg.model,
|
|
251
|
+
messages: [
|
|
252
|
+
{ role: 'system', content: SUMMARY_SYSTEM },
|
|
253
|
+
{ role: 'user', content: userMsg },
|
|
254
|
+
],
|
|
255
|
+
temperature: 0.3,
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
} catch (e) {
|
|
259
|
+
const err = new Error(`连不上 ${url}:${e.name === 'AbortError' ? '超时(90s)' : e.message}`);
|
|
260
|
+
err.status = 502;
|
|
261
|
+
throw err;
|
|
262
|
+
} finally {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const raw = await res.text();
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
const err = new Error(`AI 接口返回 ${res.status}:${trimText(raw, 300)}`);
|
|
269
|
+
err.status = 502;
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
let data;
|
|
273
|
+
try {
|
|
274
|
+
data = JSON.parse(raw);
|
|
275
|
+
} catch {
|
|
276
|
+
const err = new Error('AI 接口返回的不是 JSON');
|
|
277
|
+
err.status = 502;
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
const out = String(
|
|
281
|
+
(data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || ''
|
|
282
|
+
).trim();
|
|
283
|
+
if (!out) {
|
|
284
|
+
const err = new Error('AI 没返回内容');
|
|
285
|
+
err.status = 502;
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
return { text: out, count: rows.length, model: cfg.model, source: cfg.source };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
CONFIG_FILE,
|
|
293
|
+
publicConfig,
|
|
294
|
+
saveConfig,
|
|
295
|
+
clearConfig,
|
|
296
|
+
draftReply,
|
|
297
|
+
summarize,
|
|
298
|
+
resolveConfig,
|
|
299
|
+
DEFAULT_SYSTEM,
|
|
300
|
+
};
|
package/src/archive.js
CHANGED
|
@@ -112,13 +112,21 @@ function groupSummary(short) {
|
|
|
112
112
|
if (data.kind === 'mentions') {
|
|
113
113
|
const counts = {};
|
|
114
114
|
for (const m of msgs) {
|
|
115
|
-
|
|
115
|
+
const key = m.groupShort || `cid:${m.groupCid || ''}`;
|
|
116
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
116
117
|
}
|
|
117
|
-
data.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
data.since = doc.since || '';
|
|
119
|
+
data.sources = (doc.sources || []).map((s) => {
|
|
120
|
+
const key = s.short || `cid:${s.cid || ''}`;
|
|
121
|
+
return {
|
|
122
|
+
short: s.short || '',
|
|
123
|
+
cid: s.cid || '',
|
|
124
|
+
name: s.name || '',
|
|
125
|
+
archived: Boolean(s.short),
|
|
126
|
+
count: counts[key] || 0,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
data.sourceCount = data.sources.length; // 含未归档来源
|
|
122
130
|
}
|
|
123
131
|
return data;
|
|
124
132
|
}
|
|
@@ -149,7 +157,7 @@ function writeGroupInfoMd(short, msgsDoc, mem = {}) {
|
|
|
149
157
|
`# ${name}`, '',
|
|
150
158
|
'| 项 | 值 |', '|---|---|',
|
|
151
159
|
'| 类型 | @我 聚合视图 |',
|
|
152
|
-
`| 汇总范围 |
|
|
160
|
+
`| 汇总范围 | ${srcs.length} 个群(含未归档,点开可自动归档) |`,
|
|
153
161
|
`| 消息跨度 | ${spanS} ~ ${spanE}(${msgs.length} 条) |`,
|
|
154
162
|
`| 最近汇总 | ${pulled} |`, '',
|
|
155
163
|
'## 来源群', '',
|
|
@@ -157,7 +165,8 @@ function writeGroupInfoMd(short, msgsDoc, mem = {}) {
|
|
|
157
165
|
];
|
|
158
166
|
srcs.forEach((s, i) => {
|
|
159
167
|
const n = parts[s.name || ''] || 0;
|
|
160
|
-
|
|
168
|
+
const tag = s.short ? '' : '(未归档)';
|
|
169
|
+
lines.push(`| ${i + 1} | ${s.name || ''}${tag} | ${n} | \`${s.cid || ''}\` |`);
|
|
161
170
|
});
|
|
162
171
|
lines.push('', '> 数据源变化(新增/删除归档群)后,点一次「拉取最新消息」重新汇总。', '');
|
|
163
172
|
writeText(infoFile, lines.join('\n'));
|
package/src/auth.js
CHANGED
|
@@ -124,4 +124,36 @@ function logout() {
|
|
|
124
124
|
return { ok: true, output: text.slice(0, 500) };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/**
|
|
128
|
+
* 我自己的 openDingTalkId(用来判断「这条是不是我自己发的」,不计未读)。
|
|
129
|
+
* 取法:auth status 拿 userId + userName → contact user search 按名字查,按 userId 对上那条。
|
|
130
|
+
* 结果缓存,进程内只查一次(查不到就下次再试)。
|
|
131
|
+
*/
|
|
132
|
+
let MY_OPEN_ID = null;
|
|
133
|
+
let MY_OPEN_TRIED = 0;
|
|
134
|
+
|
|
135
|
+
function myOpenId() {
|
|
136
|
+
if (MY_OPEN_ID) return MY_OPEN_ID;
|
|
137
|
+
if (Date.now() - MY_OPEN_TRIED < 60000) return '';
|
|
138
|
+
MY_OPEN_TRIED = Date.now();
|
|
139
|
+
try {
|
|
140
|
+
const st = runDws(['auth', 'status', '-f', 'json'], { timeout: 20000 });
|
|
141
|
+
const userId = String(st.user_id || '');
|
|
142
|
+
const userName = String(st.user_name || '');
|
|
143
|
+
if (!userName) return '';
|
|
144
|
+
const res = runDws(['contact', 'user', 'search', '--query', userName, '--format', 'json'], { timeout: 20000 });
|
|
145
|
+
const rows = res.result || [];
|
|
146
|
+
const hit = userId
|
|
147
|
+
? rows.find((r) => String(r.userId || '') === userId)
|
|
148
|
+
: rows.length === 1
|
|
149
|
+
? rows[0]
|
|
150
|
+
: null;
|
|
151
|
+
const oid = hit && String(hit.openDingTalkId || '');
|
|
152
|
+
if (oid) MY_OPEN_ID = oid;
|
|
153
|
+
return MY_OPEN_ID || '';
|
|
154
|
+
} catch {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { authProfile, startLogin, loginRunning, logout, myOpenId };
|
package/src/avatars.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 全局头像库:openDingTalkId → 本地头像文件 / 外链。
|
|
5
|
+
*
|
|
6
|
+
* 钉钉 CLI 不返回人员头像 URL(通讯录、成员、好友、会话信息都没有 avatar 字段),
|
|
7
|
+
* 所以这里让用户自己挂一次,之后**同一个人的联系人条目和所有消息头像都变真图**。
|
|
8
|
+
*
|
|
9
|
+
* 存:
|
|
10
|
+
* <数据目录>/_媒体/头像/avatars.json { "<openId>": { file|url, name, at } }
|
|
11
|
+
* <数据目录>/_媒体/头像/<openId>.png 上传的本地图片
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const P = require('./paths');
|
|
18
|
+
const { ensureDir } = require('./util');
|
|
19
|
+
|
|
20
|
+
const DIR = path.join(P.MEDIA_DIR, '头像');
|
|
21
|
+
const INDEX = path.join(DIR, 'avatars.json');
|
|
22
|
+
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
|
|
23
|
+
|
|
24
|
+
function readIndex() {
|
|
25
|
+
try {
|
|
26
|
+
const j = JSON.parse(fs.readFileSync(INDEX, 'utf8'));
|
|
27
|
+
return j && typeof j === 'object' ? j : {};
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeIndex(idx) {
|
|
34
|
+
ensureDir(DIR);
|
|
35
|
+
fs.writeFileSync(INDEX, `${JSON.stringify(idx, null, 2)}\n`, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const safeKey = (openId) => String(openId || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
|
|
39
|
+
|
|
40
|
+
/** 给前端用的 { openId: 可访问 url } */
|
|
41
|
+
function list() {
|
|
42
|
+
const idx = readIndex();
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [openId, v] of Object.entries(idx)) {
|
|
45
|
+
if (!v) continue;
|
|
46
|
+
if (v.url) out[openId] = v.url;
|
|
47
|
+
else if (v.file && fs.existsSync(path.join(DIR, v.file))) {
|
|
48
|
+
out[openId] = `/api/avatar-file/${encodeURIComponent(openId)}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function remove(openId) {
|
|
55
|
+
const key = String(openId || '').trim();
|
|
56
|
+
const idx = readIndex();
|
|
57
|
+
const hit = idx[key];
|
|
58
|
+
if (hit && hit.file) {
|
|
59
|
+
try {
|
|
60
|
+
fs.unlinkSync(path.join(DIR, hit.file));
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
delete idx[key];
|
|
64
|
+
writeIndex(idx);
|
|
65
|
+
return { ok: true, avatars: list() };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** dataUrl(本地图片)或 http(s) 链接 */
|
|
69
|
+
function set({ openId, name = '', dataUrl = '', url = '', clear = false }) {
|
|
70
|
+
const key = String(openId || '').trim();
|
|
71
|
+
if (!key) {
|
|
72
|
+
const err = new Error('缺少 openDingTalkId');
|
|
73
|
+
err.status = 400;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
if (clear) return remove(key);
|
|
77
|
+
|
|
78
|
+
const idx = readIndex();
|
|
79
|
+
const prev = idx[key];
|
|
80
|
+
|
|
81
|
+
if (url) {
|
|
82
|
+
if (!/^https?:\/\/\S+$/i.test(url)) {
|
|
83
|
+
const err = new Error('只支持 http(s) 图片链接');
|
|
84
|
+
err.status = 400;
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
if (prev && prev.file) {
|
|
88
|
+
try {
|
|
89
|
+
fs.unlinkSync(path.join(DIR, prev.file));
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
idx[key] = { url, name: String(name || ''), at: new Date().toISOString() };
|
|
93
|
+
writeIndex(idx);
|
|
94
|
+
return { ok: true, url, avatars: list() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const m = String(dataUrl).match(/^data:(image\/[a-z+]+);base64,([A-Za-z0-9+/=\s]+)$/i);
|
|
98
|
+
if (!m) {
|
|
99
|
+
const err = new Error('只支持图片 dataURL(png/jpeg/webp/gif)');
|
|
100
|
+
err.status = 400;
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
const ext = EXT[m[1].toLowerCase()];
|
|
104
|
+
if (!ext) {
|
|
105
|
+
const err = new Error(`不支持的图片类型:${m[1]}`);
|
|
106
|
+
err.status = 400;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
const buf = Buffer.from(m[2].replace(/\s/g, ''), 'base64');
|
|
110
|
+
if (!buf.length) {
|
|
111
|
+
const err = new Error('图片内容为空');
|
|
112
|
+
err.status = 400;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
if (buf.length > 3 * 1024 * 1024) {
|
|
116
|
+
const err = new Error('头像太大(>3MB),先压缩一下');
|
|
117
|
+
err.status = 400;
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
if (prev && prev.file) {
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(path.join(DIR, prev.file));
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
const file = `${safeKey(key) || 'user'}.${ext}`;
|
|
126
|
+
ensureDir(DIR);
|
|
127
|
+
fs.writeFileSync(path.join(DIR, file), buf);
|
|
128
|
+
idx[key] = { file, name: String(name || ''), at: new Date().toISOString() };
|
|
129
|
+
writeIndex(idx);
|
|
130
|
+
return { ok: true, url: `/api/avatar-file/${encodeURIComponent(key)}`, avatars: list() };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 读头像文件(按 openId) */
|
|
134
|
+
function fileOf(openId) {
|
|
135
|
+
const key = String(openId || '').trim();
|
|
136
|
+
const idx = readIndex();
|
|
137
|
+
const hit = idx[key];
|
|
138
|
+
if (!hit || !hit.file) return '';
|
|
139
|
+
const file = path.join(DIR, path.basename(hit.file));
|
|
140
|
+
return fs.existsSync(file) ? file : '';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { DIR, INDEX, list, set, remove, fileOf };
|