dsh-social-trace 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +38 -2
- package/assets/NOTICE.md +3 -0
- package/assets/icon.svg +1 -0
- package/assets/weibo.ico +0 -0
- package/assets/xueqiu.png +0 -0
- package/cordis.patch.yml +3 -0
- package/lib/agent.js +198 -0
- package/lib/avatars.js +63 -0
- package/lib/capture-error.js +8 -0
- package/lib/client.js +1302 -0
- package/lib/delivery-config.js +8 -0
- package/lib/documents.js +176 -0
- package/lib/history-range.js +15 -0
- package/lib/index.js +507 -0
- package/lib/paths.js +49 -0
- package/lib/platforms/weibo/index.js +232 -0
- package/lib/platforms/xueqiu/index.js +239 -0
- package/lib/platforms/xueqiu/media.js +25 -0
- package/lib/schedule.js +102 -0
- package/lib/store.js +356 -0
- package/lib/text.js +35 -0
- package/lib/update.js +61 -0
- package/lib/worker.js +342 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Social Trace contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-Social Trace
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness 的本地社交动态监控插件,支持微博和雪球。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 可视化监控流向,按平台、博主独立启停。
|
|
8
|
+
- 定时检查新动态,按日期缓存历史消息,支持断点续传。
|
|
9
|
+
- 按平台和博主保存正文、转发及图片,支持搜索和 Markdown / JSONL 导出。
|
|
10
|
+
- 通过 dsh-im 向 QQ、微信等多个接收目标推送消息。
|
|
11
|
+
- 内置版本检查;通过 npm 安装的插件支持一键更新。
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
需要 DeepSeek Harness 桌面版、Google Chrome 和 Node.js 22.19 或以上。
|
|
16
|
+
|
|
17
|
+
在源码目录执行:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm ci
|
|
21
|
+
dsh plugin --profile desktop add .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
重新打开 Harness,在侧栏进入 Social Trace。
|
|
25
|
+
|
|
26
|
+
## 开始使用
|
|
27
|
+
|
|
28
|
+
1. 在「连接与设置」选择信息保存位置,登录对应平台。
|
|
29
|
+
2. 添加博主主页链接或 UID,选择从现在记录或缓存历史消息。
|
|
30
|
+
3. 点击大屏节点齿轮,配置监控时间、频率及历史缓存范围。
|
|
31
|
+
4. 如需推送,在 dsh-im 配好机器人和接收目标,再从大屏 dsh-im 节点添加渠道并开启推送。
|
|
32
|
+
|
|
33
|
+
## 说明
|
|
34
|
+
|
|
35
|
+
监控需要电脑唤醒且插件运行。重新启动后会补查期间仍可见的动态;已删除或无权访问的内容无法补存。平台要求登录或验证时会暂停采集,处理后可继续。雪球文章仅保存标题和原文链接。
|
|
36
|
+
|
|
37
|
+
## 许可
|
|
38
|
+
|
|
39
|
+
[MIT](LICENSE)。平台标识的权利说明见 [assets/NOTICE.md](assets/NOTICE.md)。
|
package/assets/NOTICE.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
微博标识(weibo.ico)取自微博官网 https://weibo.com/favicon.ico(2026-11-05),仅用于标识监控平台。商标与图形权利归相关权利人所有,不包含在本项目 MIT 授权范围内。
|
|
2
|
+
|
|
3
|
+
雪球标识(xueqiu.png)取自雪球官网 https://xueqiu.com/ 的 favicon 链接 https://xqimg.imedao.com/17af5fe80fb1844b3fd48941.png(2026-11-06),仅用于标识平台。商标与图形权利归相关权利人所有,不包含在本项目 MIT 授权范围内。
|
package/assets/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#15251f"/><g fill="none" stroke="#a0dfc1" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M17 17h30v25H28L17 50V17Z"/><path d="M24 26h16M24 34h11"/></g></svg>
|
package/assets/weibo.ico
ADDED
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
package/lib/agent.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { deliveryDestinations, activeDestinations } from './delivery-config.js';
|
|
2
|
+
const defaults = { enabled: false, botId: '', targetId: '', uids: [] };
|
|
3
|
+
export const agentConfig = store => store.getSetting('agentDelivery', defaults);
|
|
4
|
+
|
|
5
|
+
export class SocialAgent {
|
|
6
|
+
constructor(getStore, getIM, paused = () => false) {
|
|
7
|
+
this.getStore = getStore;
|
|
8
|
+
this.getIM = getIM;
|
|
9
|
+
this.paused = paused;
|
|
10
|
+
this.timer = null;
|
|
11
|
+
this.startedAt = Date.now();
|
|
12
|
+
this.connections = { bots: [], targets: {}, checkedAt: 0, error: '' };
|
|
13
|
+
this.refreshing = null;
|
|
14
|
+
this.busy = null;
|
|
15
|
+
this.controller = new AbortController();
|
|
16
|
+
}
|
|
17
|
+
async refreshConnections() {
|
|
18
|
+
if (this.refreshing) return this.refreshing;
|
|
19
|
+
const im = this.getIM(), config = agentConfig(this.getStore());
|
|
20
|
+
if (!im?.listBots || !im?.listTargets) return;
|
|
21
|
+
this.refreshing = (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const bots = (await im.listBots()).map(({ botId, channel }) => ({ botId, channel }));
|
|
24
|
+
const targets = {};
|
|
25
|
+
for (const botId of new Set(deliveryDestinations(config).map(item => item.botId))) {
|
|
26
|
+
if (!bots.some(bot => bot.botId === botId)) continue;
|
|
27
|
+
try { targets[botId] = (await im.listTargets(botId)).map(({ targetId, name }) => ({ targetId, name })); }
|
|
28
|
+
catch { targets[botId] = null; }
|
|
29
|
+
}
|
|
30
|
+
this.connections = { bots, targets, checkedAt: Date.now(), error: '' };
|
|
31
|
+
} catch { this.connections = { ...this.connections, checkedAt: Date.now(), error: '无法读取 dsh-im 配置,请检查插件' }; }
|
|
32
|
+
})();
|
|
33
|
+
try { await this.refreshing; } finally { this.refreshing = null; }
|
|
34
|
+
}
|
|
35
|
+
status() {
|
|
36
|
+
const store = this.getStore(), config = agentConfig(store);
|
|
37
|
+
const transportAvailable = ['send', 'listBots', 'listTargets'].every(key => typeof this.getIM()?.[key] === 'function');
|
|
38
|
+
if (transportAvailable && Date.now() - this.connections.checkedAt > 30000) void this.refreshConnections();
|
|
39
|
+
const destinations = deliveryDestinations(config).map(item => {
|
|
40
|
+
const bot = this.connections.bots.find(bot => bot.botId === item.botId);
|
|
41
|
+
const target = this.connections.targets[item.botId]?.find(target => target.targetId === item.targetId);
|
|
42
|
+
const delivery = store.db.prepare("SELECT status,error,sent_at,finished_at,created_at FROM social_deliveries WHERE bot_id=? AND target_id=? AND status IN ('sent','failed','uncertain') ORDER BY COALESCE(finished_at,sent_at,created_at) DESC,id DESC LIMIT 1").get(item.botId, item.targetId);
|
|
43
|
+
const test = store.getSetting('imTest:' + item.botId + ':' + item.targetId, null);
|
|
44
|
+
const last = test && test.created_at > (delivery?.finished_at || delivery?.sent_at || delivery?.created_at || 0) ? test : delivery;
|
|
45
|
+
const state = !transportAvailable ? 'unavailable' : !config.enabled || item.enabled === false ? 'paused'
|
|
46
|
+
: !this.connections.checkedAt ? 'checking' : this.connections.error || !bot || !target ? 'invalid'
|
|
47
|
+
: last && ['failed', 'uncertain'].includes(last.status) ? 'failed' : last?.status === 'sent' ? 'accepted' : 'configured';
|
|
48
|
+
return { ...item, channel: bot?.channel || item.channel || '', name: target?.name || item.name || item.targetId, state, last };
|
|
49
|
+
});
|
|
50
|
+
return { config, transportAvailable, destinations, integration: this.connections, summaryAvailable: false,
|
|
51
|
+
deliveries: store.db.prepare('SELECT d.id,d.author,d.bot_id,d.target_id,d.status,d.error,d.created_at,d.sent_at,d.finished_at,p.published_at,p.detected_at,p.first_seen_at AS saved_at FROM social_deliveries d LEFT JOIN posts p ON p.id=d.post_id ORDER BY d.created_at DESC,d.id DESC LIMIT 30').all() };
|
|
52
|
+
}
|
|
53
|
+
async bots() {
|
|
54
|
+
const im = this.getIM();
|
|
55
|
+
if (typeof im?.listBots !== 'function') return [];
|
|
56
|
+
return (await im.listBots()).map(({ botId, channel }) => ({ botId, channel }));
|
|
57
|
+
}
|
|
58
|
+
async targets(botId) {
|
|
59
|
+
const im = this.getIM();
|
|
60
|
+
if (typeof im?.listTargets !== 'function') throw new Error('尚未连接 dsh-im,请在 Harness 中启用 IM 机器人插件');
|
|
61
|
+
return (await im.listTargets(botId)).map(({ targetId, name, kind }) => ({ targetId, name, kind }));
|
|
62
|
+
}
|
|
63
|
+
async validate(body, { force = false } = {}) {
|
|
64
|
+
if (typeof body.enabled !== 'boolean' || !Array.isArray(body.uids) || body.uids.length > 2000 || body.uids.some(uid => typeof uid !== 'string' || !this.getStore().target(uid))) throw new Error('请选择有效的推送开关与博主');
|
|
65
|
+
const multiple = Array.isArray(body.destinations);
|
|
66
|
+
const input = multiple ? body.destinations : [{ botId: body.botId, targetId: body.targetId, enabled: true }];
|
|
67
|
+
if (input.length > 20) throw new Error('最多配置 20 个推送目标');
|
|
68
|
+
const destinations = [], keys = new Set(), current = agentConfig(this.getStore());
|
|
69
|
+
let bots;
|
|
70
|
+
for (const item of input) {
|
|
71
|
+
if (typeof item.botId !== 'string' || typeof item.targetId !== 'string' || item.enabled !== undefined && typeof item.enabled !== 'boolean') throw new Error('请选择有效的机器人和接收目标');
|
|
72
|
+
const value = { botId: item.botId.trim(), targetId: item.targetId.trim(), enabled: item.enabled !== false };
|
|
73
|
+
if (value.botId.length > 256 || value.targetId.length > 256) throw new Error('机器人或目标标识格式无效,请从 dsh-im 选择');
|
|
74
|
+
const previous = deliveryDestinations(current).find(row => row.botId === value.botId && row.targetId === value.targetId);
|
|
75
|
+
if (previous?.channel) value.channel = previous.channel;
|
|
76
|
+
if (previous?.name) value.name = previous.name;
|
|
77
|
+
const key = value.botId + ':' + value.targetId;
|
|
78
|
+
if (keys.has(key)) throw new Error('该接收目标已添加');
|
|
79
|
+
keys.add(key);
|
|
80
|
+
if (multiple && (!value.botId || !value.targetId)) throw new Error('请选择机器人和接收目标');
|
|
81
|
+
if (body.enabled && value.enabled && (force || !current.enabled || !previous || previous.enabled === false)) {
|
|
82
|
+
if (typeof this.getIM()?.send !== 'function') throw new Error('dsh-im 尚未连接,不能开启推送');
|
|
83
|
+
bots ??= await this.bots();
|
|
84
|
+
const bot = bots.find(bot => bot.botId === value.botId);
|
|
85
|
+
const target = bot && (await this.targets(value.botId)).find(target => target.targetId === value.targetId);
|
|
86
|
+
if (!target) throw new Error('请先在 dsh-im 保存机器人和接收目标,再刷新选择');
|
|
87
|
+
value.channel = bot.channel; value.name = target.name || value.targetId;
|
|
88
|
+
}
|
|
89
|
+
destinations.push(value);
|
|
90
|
+
}
|
|
91
|
+
if (body.enabled && !destinations.some(item => item.enabled)) throw new Error('请至少开启一个接收目标');
|
|
92
|
+
return multiple ? { enabled: body.enabled, uids: [...new Set(body.uids)], destinations }
|
|
93
|
+
: { enabled: body.enabled, botId: destinations[0].botId, targetId: destinations[0].targetId, uids: [...new Set(body.uids)] };
|
|
94
|
+
}
|
|
95
|
+
async configure(body) {
|
|
96
|
+
const value = await this.validate(body), store = this.getStore();
|
|
97
|
+
const previous = agentConfig(store);
|
|
98
|
+
store.transaction(() => {
|
|
99
|
+
store.setSetting('agentDelivery', value);
|
|
100
|
+
const destinations = deliveryDestinations(value);
|
|
101
|
+
for (const item of activeDestinations(value)) {
|
|
102
|
+
if (!activeDestinations(previous).some(old => old.botId === item.botId && old.targetId === item.targetId)) store.setSetting('deliveryFrom:' + item.botId + ':' + item.targetId, Date.now());
|
|
103
|
+
}
|
|
104
|
+
for (const row of store.db.prepare("SELECT id,bot_id,target_id FROM social_deliveries WHERE status='queued'").all()) {
|
|
105
|
+
if (!destinations.some(item => item.botId === row.bot_id && item.targetId === row.target_id)) store.db.prepare("UPDATE social_deliveries SET status='cancelled',error='接收目标已移除' WHERE id=?").run(row.id);
|
|
106
|
+
}
|
|
107
|
+
for (const platform of ['weibo', 'xueqiu']) if (!Array.isArray(previous.destinations) && store.getSetting('accountAlerted:' + platform, false)) {
|
|
108
|
+
for (const item of deliveryDestinations(previous)) store.setSetting('accountAlerted:' + platform + ':' + item.botId + ':' + item.targetId, true);
|
|
109
|
+
}
|
|
110
|
+
for (const platform of ['weibo', 'xueqiu']) for (const item of destinations) {
|
|
111
|
+
if (!deliveryDestinations(previous).some(old => old.botId === item.botId && old.targetId === item.targetId)) store.setSetting('accountAlerted:' + platform + ':' + item.botId + ':' + item.targetId, false);
|
|
112
|
+
}
|
|
113
|
+
if (value.uids.length) for (const row of store.db.prepare("SELECT id,uid FROM social_deliveries WHERE status='queued' AND message=''").all()) if (!value.uids.includes(row.uid)) store.db.prepare("UPDATE social_deliveries SET status='cancelled',error='此博主已移出推送范围' WHERE id=?").run(row.id);
|
|
114
|
+
})();
|
|
115
|
+
for (const platform of ['weibo', 'xueqiu']) {
|
|
116
|
+
const account = store.account(platform);
|
|
117
|
+
if (value.enabled && account?.uid && ['needs_login', 'needs_verification'].includes(account.status)) store.setAccount({ status: account.status, reason: account.reason }, platform, true);
|
|
118
|
+
}
|
|
119
|
+
if (this.refreshing) await this.refreshing;
|
|
120
|
+
this.connections.checkedAt = 0;
|
|
121
|
+
await this.refreshConnections();
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
async test(body) {
|
|
125
|
+
const value = await this.validate({ enabled: true, botId: body.botId, targetId: body.targetId, uids: body.uids || [] }, { force: true });
|
|
126
|
+
const store = this.getStore(), key = 'imTest:' + value.botId + ':' + value.targetId;
|
|
127
|
+
try {
|
|
128
|
+
const result = await this.getIM().send(value.botId, value.targetId, 'Social Trace 推送连接测试:这是一条由你主动发送的测试消息。', { signal: AbortSignal.timeout(15000) });
|
|
129
|
+
if (result?.sent !== true) throw new Error('dsh-im 未确认接受测试消息');
|
|
130
|
+
store.setSetting(key, { status: 'sent', error: '', created_at: Date.now(), sent_at: Date.now() });
|
|
131
|
+
store.setSetting('imDeliveryAlert:' + value.botId + ':' + value.targetId, false);
|
|
132
|
+
return { sent: true };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
store.setSetting(key, { status: 'uncertain', error: String(error.message || '发送未确认').slice(0, 300), created_at: Date.now() });
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
start() {
|
|
140
|
+
if (this.timer) return;
|
|
141
|
+
this.controller = new AbortController();
|
|
142
|
+
this.startedAt = Date.now();
|
|
143
|
+
const store = this.getStore(), config = agentConfig(store);
|
|
144
|
+
const pending = activeDestinations(config).reduce((total, item) => total + store.db.prepare("SELECT COUNT(*) AS n FROM social_deliveries WHERE status='queued' AND message='' AND bot_id=? AND target_id=? AND created_at<?").get(item.botId, item.targetId, this.startedAt).n, 0);
|
|
145
|
+
if (pending) store.notice('task', '恢复推送:上次运行时已保存的 ' + pending + ' 条动态尚未发送,将继续推送;这不是刚发布的新消息。');
|
|
146
|
+
for (const platform of ['weibo', 'xueqiu']) {
|
|
147
|
+
const account = this.getStore().account(platform);
|
|
148
|
+
if (account?.uid && ['needs_login', 'needs_verification'].includes(account.status)) this.getStore().setAccount({ status: account.status, reason: account.reason }, platform, true);
|
|
149
|
+
}
|
|
150
|
+
this.getStore().db.prepare("UPDATE social_deliveries SET status='uncertain',finished_at=?,error='上次投递时进程中断,结果未知;未自动重发' WHERE status='sending'").run(Date.now());
|
|
151
|
+
this.timer = setInterval(() => { void this.tick(); }, 5000);
|
|
152
|
+
this.timer.unref?.();
|
|
153
|
+
}
|
|
154
|
+
async tick() {
|
|
155
|
+
if (this.busy || this.paused() || this.controller.signal.aborted) return;
|
|
156
|
+
const store = this.getStore(), config = agentConfig(store), im = this.getIM();
|
|
157
|
+
if (!config.enabled || !im?.send) return;
|
|
158
|
+
const destinations = activeDestinations(config);
|
|
159
|
+
if (!destinations.length) return;
|
|
160
|
+
const where = destinations.map(() => '(bot_id=? AND target_id=?)').join(' OR ');
|
|
161
|
+
const row = store.db.prepare("SELECT * FROM social_deliveries WHERE status='queued' AND (" + where + ") AND (? OR message<>'') ORDER BY (message<>'') DESC,created_at,id LIMIT 1").get(...destinations.flatMap(item => [item.botId, item.targetId]), store.getSetting('monitorEnabled', true) === false ? 0 : 1);
|
|
162
|
+
if (!row) return;
|
|
163
|
+
const post = row.message ? null : store.posts({ ids: [row.post_id], limit: 1 })[0];
|
|
164
|
+
if (!post && !row.message) { store.db.prepare("UPDATE social_deliveries SET status='cancelled',error='原文不存在' WHERE id=?").run(row.id); return; }
|
|
165
|
+
store.db.prepare("UPDATE social_deliveries SET status='sending' WHERE id=?").run(row.id);
|
|
166
|
+
this.busy = (async () => {
|
|
167
|
+
try {
|
|
168
|
+
const stamp = time => new Date(time).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
|
169
|
+
const prefix = post?.capture_kind === 'catchup' ? '离线补查通知:恢复运行后补查到的内容,并非刚刚发布。\n' : row.created_at < this.startedAt ? '恢复待发通知:上次运行时已保存,本次继续发送。\n' : '';
|
|
170
|
+
const text = row.message || prefix + `${post.author} · ${post.quote ? '转发' : '新动态'}\n博主发布:${stamp(post.published_at)}\n监控发现:${post.detected_at ? stamp(post.detected_at) : '旧记录未记录'}\n首次保存:${stamp(post.first_seen_at)}\n发起推送:${stamp(Date.now())}(以上均为北京时间)\n\n${post.text.slice(0, 1400)}${post.quote ? '\n\n引用 ' + post.quote.author + ':' + post.quote.text.slice(0, 400) : ''}\n\n原文:${post.url}`;
|
|
171
|
+
const result = await im.send(row.bot_id, row.target_id, text, { signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(15000)]) });
|
|
172
|
+
if (result?.sent !== true) throw new Error('dsh-im 未确认接受消息');
|
|
173
|
+
store.setSetting('imDeliveryAlert:' + row.bot_id + ':' + row.target_id, false);
|
|
174
|
+
const finishedAt = Date.now();
|
|
175
|
+
store.db.prepare("UPDATE social_deliveries SET status='sent',sent_at=?,finished_at=?,error='' WHERE id=?").run(finishedAt, finishedAt, row.id);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const status = ['unknown-bot', 'unknown-target', 'invalid-target', 'target-rejected', 'bot-not-connected'].includes(error.code) ? 'failed' : 'uncertain';
|
|
178
|
+
store.db.prepare('UPDATE social_deliveries SET status=?,finished_at=?,error=? WHERE id=?').run(status, Date.now(), String(error.message || '投递失败').slice(0, 300), row.id);
|
|
179
|
+
const alertKey = 'imDeliveryAlert:' + row.bot_id + ':' + row.target_id;
|
|
180
|
+
if (!store.getSetting(alertKey, false)) {
|
|
181
|
+
const destination = deliveryDestinations(config).find(item => item.botId === row.bot_id && item.targetId === row.target_id);
|
|
182
|
+
const channel = destination?.channel || this.connections.bots.find(bot => bot.botId === row.bot_id)?.channel || 'IM';
|
|
183
|
+
const recipient = '\n接收渠道:' + ({ weixin: '微信', qq: 'QQ' }[channel] || channel) + ' · ' + (destination?.name || row.target_id);
|
|
184
|
+
store.notice('attention', error.code === 'delivery-failed' || error.message === 'delivery-failed'
|
|
185
|
+
? 'dsh-im 插件发送失败:请检查 dsh-im 的机器人连接和接收目标。消息已保存在本地,未确认是否送达;详见推送记录。' + recipient
|
|
186
|
+
: 'dsh-im 推送未确认:请检查机器人连接与接收目标,详见推送记录。消息已保存在本地。' + recipient, Date.now());
|
|
187
|
+
store.setSetting(alertKey, true);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
try { await this.busy; } finally { this.busy = null; }
|
|
192
|
+
}
|
|
193
|
+
async close() {
|
|
194
|
+
clearInterval(this.timer); this.timer = null;
|
|
195
|
+
this.controller.abort();
|
|
196
|
+
if (this.busy) await this.busy;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/lib/avatars.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { imageReferer } from './text.js';
|
|
6
|
+
|
|
7
|
+
const types = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
8
|
+
const limit = 2 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export class Avatars {
|
|
11
|
+
constructor(directory, fetchImage = fetch) {
|
|
12
|
+
this.directory = directory;
|
|
13
|
+
this.fetchImage = fetchImage;
|
|
14
|
+
this.pending = new Map();
|
|
15
|
+
this.controller = new AbortController();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(image) {
|
|
19
|
+
if (!this.pending.has(image)) {
|
|
20
|
+
const task = this.load(image).finally(() => this.pending.delete(image));
|
|
21
|
+
this.pending.set(image, task);
|
|
22
|
+
}
|
|
23
|
+
return this.pending.get(image);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async load(image) {
|
|
27
|
+
const file = join(this.directory, createHash('sha256').update(image).digest('hex') + '.avatar');
|
|
28
|
+
try {
|
|
29
|
+
const cached = await readFile(file);
|
|
30
|
+
const end = cached.indexOf(10);
|
|
31
|
+
const type = cached.subarray(0, end).toString();
|
|
32
|
+
if (end > 0 && types.has(type) && cached.length > end + 1 && cached.length <= limit + end + 1) return { type, bytes: cached.subarray(end + 1) };
|
|
33
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
34
|
+
const response = await this.fetchImage(image, { headers: { Referer: imageReferer(image) }, redirect: 'error',
|
|
35
|
+
signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]) });
|
|
36
|
+
const type = response.headers.get('content-type')?.split(';')[0];
|
|
37
|
+
if (!response.ok || !types.has(type) || Number(response.headers.get('content-length')) > limit) {
|
|
38
|
+
await response.body?.cancel();
|
|
39
|
+
throw new Error('头像暂时无法读取');
|
|
40
|
+
}
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let length = 0;
|
|
43
|
+
for await (const chunk of response.body) {
|
|
44
|
+
length += chunk.length;
|
|
45
|
+
if (length > limit) throw new Error('头像文件过大');
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
}
|
|
48
|
+
if (!length) throw new Error('头像内容为空');
|
|
49
|
+
const bytes = Buffer.concat(chunks, length);
|
|
50
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
51
|
+
const temporary = file + '.' + randomUUID() + '.tmp';
|
|
52
|
+
try {
|
|
53
|
+
await writeFile(temporary, Buffer.concat([Buffer.from(type + '\n'), bytes]), { mode: 0o600 });
|
|
54
|
+
await rename(temporary, file);
|
|
55
|
+
} finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
56
|
+
return { type, bytes };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async close() {
|
|
60
|
+
this.controller.abort();
|
|
61
|
+
await Promise.allSettled(this.pending.values());
|
|
62
|
+
}
|
|
63
|
+
}
|