@xmanrui/dsh-im 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 +143 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/dsh-im.mjs +99 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +6211 -0
- package/lib/index.js +13339 -0
- package/package.json +73 -0
- package/plugin-src/client/build.mjs +38 -0
- package/plugin-src/client/channel-logos.js +107 -0
- package/plugin-src/client/channels/dingtalk/api.js +203 -0
- package/plugin-src/client/channels/dingtalk/index.js +709 -0
- package/plugin-src/client/channels/dingtalk/styles.js +140 -0
- package/plugin-src/client/channels/discord/api.js +11 -0
- package/plugin-src/client/channels/discord/index.js +25 -0
- package/plugin-src/client/channels/discord/styles.js +19 -0
- package/plugin-src/client/channels/feishu/api.js +325 -0
- package/plugin-src/client/channels/feishu/index.js +986 -0
- package/plugin-src/client/channels/feishu/styles.js +505 -0
- package/plugin-src/client/channels/qq/api.js +119 -0
- package/plugin-src/client/channels/qq/index.js +402 -0
- package/plugin-src/client/channels/qq/styles.js +19 -0
- package/plugin-src/client/channels/shared/token-api.js +88 -0
- package/plugin-src/client/channels/shared/token-channel.js +277 -0
- package/plugin-src/client/channels/telegram/api.js +11 -0
- package/plugin-src/client/channels/telegram/index.js +25 -0
- package/plugin-src/client/channels/telegram/styles.js +19 -0
- package/plugin-src/client/channels/wecom/api.js +119 -0
- package/plugin-src/client/channels/wecom/index.js +402 -0
- package/plugin-src/client/channels/wecom/styles.js +19 -0
- package/plugin-src/client/channels/weixin/api.js +156 -0
- package/plugin-src/client/channels/weixin/index.js +577 -0
- package/plugin-src/client/channels/weixin/styles.js +120 -0
- package/plugin-src/client/credential-binding.js +117 -0
- package/plugin-src/client/index.js +192 -0
- package/plugin-src/client/lifecycle.js +86 -0
- package/plugin-src/client/styles.js +192 -0
- package/plugin-src/host/build.mjs +24 -0
- package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
- package/plugin-src/host/channels/discord/index.mjs +30 -0
- package/plugin-src/host/channels/discord/production.mjs +17 -0
- package/plugin-src/host/channels/discord/rpc.mjs +21 -0
- package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
- package/plugin-src/host/channels/feishu/controller.mjs +172 -0
- package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
- package/plugin-src/host/channels/feishu/index.mjs +73 -0
- package/plugin-src/host/channels/feishu/production.mjs +138 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
- package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/qq/index.mjs +30 -0
- package/plugin-src/host/channels/qq/production.mjs +109 -0
- package/plugin-src/host/channels/qq/rpc.mjs +146 -0
- package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
- package/plugin-src/host/channels/shared/production.mjs +104 -0
- package/plugin-src/host/channels/shared/rpc.mjs +119 -0
- package/plugin-src/host/channels/telegram/index.mjs +30 -0
- package/plugin-src/host/channels/telegram/production.mjs +17 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
- package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/wecom/index.mjs +36 -0
- package/plugin-src/host/channels/wecom/production.mjs +113 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
- package/plugin-src/host/channels/weixin/index.mjs +39 -0
- package/plugin-src/host/channels/weixin/production.mjs +119 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
- package/plugin-src/host/index.mjs +44 -0
- package/plugin-src/host/rpc-authority.mjs +11 -0
- package/scripts/verify-package.mjs +97 -0
- package/src/channels/dingtalk/config-store.mjs +282 -0
- package/src/channels/dingtalk/device-auth.mjs +237 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
- package/src/channels/dingtalk/harness-client.mjs +299 -0
- package/src/channels/dingtalk/state-store.mjs +212 -0
- package/src/channels/discord/config-store.mjs +24 -0
- package/src/channels/discord/discord-api.mjs +153 -0
- package/src/channels/discord/discord-bridge.mjs +15 -0
- package/src/channels/discord/discord-controller.mjs +16 -0
- package/src/channels/discord/discord-runtime.mjs +460 -0
- package/src/channels/discord/harness-client.mjs +3 -0
- package/src/channels/discord/state-store.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +216 -0
- package/src/channels/feishu/config.mjs +71 -0
- package/src/channels/feishu/feishu-app.mjs +51 -0
- package/src/channels/feishu/feishu-channel.mjs +153 -0
- package/src/channels/feishu/feishu-runtime.mjs +222 -0
- package/src/channels/feishu/harness-client.mjs +268 -0
- package/src/channels/feishu/message-utils.mjs +51 -0
- package/src/channels/feishu/multi-bot-controller.mjs +686 -0
- package/src/channels/feishu/plugin-config-store.mjs +204 -0
- package/src/channels/feishu/plugin-controller.mjs +248 -0
- package/src/channels/feishu/registration-manager.mjs +345 -0
- package/src/channels/feishu/state-store.mjs +71 -0
- package/src/channels/qq/config-store.mjs +171 -0
- package/src/channels/qq/harness-client.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +177 -0
- package/src/channels/qq/qq-controller.mjs +452 -0
- package/src/channels/qq/qq-runtime.mjs +208 -0
- package/src/channels/qq/qr-auth.mjs +24 -0
- package/src/channels/qq/state-store.mjs +96 -0
- package/src/channels/shared/conversation-state-store.mjs +107 -0
- package/src/channels/shared/editable-message-stream.mjs +81 -0
- package/src/channels/shared/text-harness-bridge.mjs +180 -0
- package/src/channels/shared/token-bot-controller.mjs +298 -0
- package/src/channels/shared/token-config-store.mjs +180 -0
- package/src/channels/telegram/config-store.mjs +24 -0
- package/src/channels/telegram/harness-client.mjs +3 -0
- package/src/channels/telegram/state-store.mjs +3 -0
- package/src/channels/telegram/telegram-api.mjs +120 -0
- package/src/channels/telegram/telegram-bridge.mjs +15 -0
- package/src/channels/telegram/telegram-controller.mjs +16 -0
- package/src/channels/telegram/telegram-runtime.mjs +278 -0
- package/src/channels/wecom/config-store.mjs +170 -0
- package/src/channels/wecom/harness-client.mjs +3 -0
- package/src/channels/wecom/qr-auth.mjs +95 -0
- package/src/channels/wecom/state-store.mjs +90 -0
- package/src/channels/wecom/wecom-bridge.mjs +243 -0
- package/src/channels/wecom/wecom-controller.mjs +474 -0
- package/src/channels/wecom/wecom-runtime.mjs +209 -0
- package/src/channels/weixin/config-store.mjs +182 -0
- package/src/channels/weixin/harness-client.mjs +259 -0
- package/src/channels/weixin/state-store.mjs +112 -0
- package/src/channels/weixin/weixin-api.mjs +319 -0
- package/src/channels/weixin/weixin-bridge.mjs +173 -0
- package/src/channels/weixin/weixin-controller.mjs +545 -0
- package/src/channels/weixin/weixin-runtime.mjs +204 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
|
|
2
|
+
|
|
3
|
+
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
|
|
4
|
+
|
|
5
|
+
function timeoutError() {
|
|
6
|
+
const error = new Error('QQ WebSocket did not become ready in time');
|
|
7
|
+
error.code = 'connect-timeout';
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createQqRuntimeStatus() {
|
|
12
|
+
return {
|
|
13
|
+
startedAt: null,
|
|
14
|
+
ready: false,
|
|
15
|
+
qqConnectionState: 'idle',
|
|
16
|
+
harnessReachable: false,
|
|
17
|
+
lastCheckedAt: null,
|
|
18
|
+
lastConnectedAt: null,
|
|
19
|
+
lastError: null,
|
|
20
|
+
...createQqBridgeStatus(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class QqRuntime {
|
|
25
|
+
#config;
|
|
26
|
+
#appSecret;
|
|
27
|
+
#harness;
|
|
28
|
+
#state;
|
|
29
|
+
#logger;
|
|
30
|
+
#replyTimeoutMs;
|
|
31
|
+
#connectTimeoutMs;
|
|
32
|
+
#createBot;
|
|
33
|
+
#typingMiddleware;
|
|
34
|
+
#status = createQqRuntimeStatus();
|
|
35
|
+
#bot = null;
|
|
36
|
+
#bridge = null;
|
|
37
|
+
#abortController = null;
|
|
38
|
+
#runTask = null;
|
|
39
|
+
#starting = null;
|
|
40
|
+
|
|
41
|
+
constructor({
|
|
42
|
+
config,
|
|
43
|
+
appSecret,
|
|
44
|
+
harness,
|
|
45
|
+
state,
|
|
46
|
+
logger = console,
|
|
47
|
+
replyTimeoutMs = 600_000,
|
|
48
|
+
connectTimeoutMs = 20_000,
|
|
49
|
+
createBot = (options) => new QQBot(options),
|
|
50
|
+
typingMiddleware = typingIndicator,
|
|
51
|
+
}) {
|
|
52
|
+
if (!config || !appSecret || !harness || !state) {
|
|
53
|
+
throw new TypeError('QqRuntime requires config, app secret, Harness, and state');
|
|
54
|
+
}
|
|
55
|
+
this.#config = config;
|
|
56
|
+
this.#appSecret = appSecret;
|
|
57
|
+
this.#harness = harness;
|
|
58
|
+
this.#state = state;
|
|
59
|
+
this.#logger = logger;
|
|
60
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
61
|
+
this.#connectTimeoutMs = connectTimeoutMs;
|
|
62
|
+
this.#createBot = createBot;
|
|
63
|
+
this.#typingMiddleware = typingMiddleware;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get status() {
|
|
67
|
+
return structuredClone(this.#status);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async start() {
|
|
71
|
+
if (this.#status.ready && this.#bot) return this.status;
|
|
72
|
+
if (this.#starting) return this.#starting;
|
|
73
|
+
this.#starting = this.#start().finally(() => {
|
|
74
|
+
this.#starting = null;
|
|
75
|
+
});
|
|
76
|
+
return this.#starting;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async #start() {
|
|
80
|
+
await this.stop();
|
|
81
|
+
this.#status.startedAt = new Date().toISOString();
|
|
82
|
+
this.#status.qqConnectionState = 'connecting';
|
|
83
|
+
this.#status.lastError = null;
|
|
84
|
+
await this.#harness.ensureRunning();
|
|
85
|
+
this.#status.harnessReachable = true;
|
|
86
|
+
|
|
87
|
+
const sdkLogger = {
|
|
88
|
+
error: (...args) => this.#logger.error?.(...args),
|
|
89
|
+
warn: (...args) => this.#logger.warn?.(...args),
|
|
90
|
+
info: (...args) => this.#logger.info?.(...args),
|
|
91
|
+
debug: () => {},
|
|
92
|
+
};
|
|
93
|
+
const bot = this.#createBot({
|
|
94
|
+
appId: this.#config.appId,
|
|
95
|
+
appSecret: this.#appSecret,
|
|
96
|
+
accountId: this.#config.botId,
|
|
97
|
+
logger: sdkLogger,
|
|
98
|
+
transport: 'websocket',
|
|
99
|
+
tokenPrefetch: 'sync',
|
|
100
|
+
});
|
|
101
|
+
if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
|
|
102
|
+
throw new TypeError('QQ bot factory returned an invalid client');
|
|
103
|
+
}
|
|
104
|
+
this.#bot = bot;
|
|
105
|
+
this.#bridge = new QqHarnessBridge({
|
|
106
|
+
bot,
|
|
107
|
+
ownerUserOpenid: this.#config.ownerUserOpenid,
|
|
108
|
+
harness: this.#harness,
|
|
109
|
+
state: this.#state,
|
|
110
|
+
status: this.#status,
|
|
111
|
+
logger: this.#logger,
|
|
112
|
+
replyTimeoutMs: this.#replyTimeoutMs,
|
|
113
|
+
});
|
|
114
|
+
bot.use?.(this.#typingMiddleware({
|
|
115
|
+
keepAlive: true,
|
|
116
|
+
predicate: (ctx) => this.#config.ownerUserOpenid === '*'
|
|
117
|
+
|| ctx?.message?.senderId === this.#config.ownerUserOpenid,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
this.#abortController = controller;
|
|
122
|
+
let readyResolve;
|
|
123
|
+
let readyReject;
|
|
124
|
+
const ready = new Promise((resolve, reject) => {
|
|
125
|
+
readyResolve = resolve;
|
|
126
|
+
readyReject = reject;
|
|
127
|
+
});
|
|
128
|
+
const onReady = () => {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
this.#status.ready = true;
|
|
131
|
+
this.#status.qqConnectionState = 'connected';
|
|
132
|
+
this.#status.lastCheckedAt = now;
|
|
133
|
+
this.#status.lastConnectedAt = now;
|
|
134
|
+
this.#status.lastError = null;
|
|
135
|
+
readyResolve();
|
|
136
|
+
};
|
|
137
|
+
const onError = (error) => {
|
|
138
|
+
if (!this.#status.ready) readyReject(error);
|
|
139
|
+
else {
|
|
140
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
141
|
+
this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const onMessage = (_ctx, message) => this.#bridge?.accept(message);
|
|
145
|
+
bot.on('ready', onReady);
|
|
146
|
+
bot.on('resumed', onReady);
|
|
147
|
+
bot.on('error', onError);
|
|
148
|
+
bot.on('message', onMessage);
|
|
149
|
+
|
|
150
|
+
const runTask = Promise.resolve().then(() => bot.start(controller.signal));
|
|
151
|
+
this.#runTask = runTask;
|
|
152
|
+
runTask.catch((error) => {
|
|
153
|
+
if (controller.signal.aborted) return;
|
|
154
|
+
readyReject(error);
|
|
155
|
+
this.#status.ready = false;
|
|
156
|
+
this.#status.qqConnectionState = 'failed';
|
|
157
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
158
|
+
this.#logger.error?.(`[dsh-im:qq] bot ${this.#config.botId} connection stopped:`, error);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let timer;
|
|
162
|
+
try {
|
|
163
|
+
await Promise.race([
|
|
164
|
+
ready,
|
|
165
|
+
new Promise((_, reject) => {
|
|
166
|
+
timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs);
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
this.#status.ready = true;
|
|
170
|
+
this.#status.qqConnectionState = 'connected';
|
|
171
|
+
this.#status.lastCheckedAt = Date.now();
|
|
172
|
+
this.#status.lastConnectedAt = Date.now();
|
|
173
|
+
return this.status;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
this.#status.ready = false;
|
|
176
|
+
this.#status.qqConnectionState = 'failed';
|
|
177
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
178
|
+
await this.stop();
|
|
179
|
+
throw error;
|
|
180
|
+
} finally {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async stop() {
|
|
186
|
+
const bot = this.#bot;
|
|
187
|
+
const bridge = this.#bridge;
|
|
188
|
+
const runTask = this.#runTask;
|
|
189
|
+
this.#abortController?.abort();
|
|
190
|
+
this.#abortController = null;
|
|
191
|
+
this.#bot = null;
|
|
192
|
+
this.#bridge = null;
|
|
193
|
+
this.#runTask = null;
|
|
194
|
+
try {
|
|
195
|
+
bot?.stop();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} failed to stop cleanly:`, error);
|
|
198
|
+
}
|
|
199
|
+
await Promise.race([
|
|
200
|
+
runTask?.catch(() => undefined) ?? Promise.resolve(),
|
|
201
|
+
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
|
202
|
+
]);
|
|
203
|
+
await bridge?.waitForIdle();
|
|
204
|
+
this.#status.ready = false;
|
|
205
|
+
this.#status.qqConnectionState = 'idle';
|
|
206
|
+
return this.status;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { startQrConnect } from '@tencent-connect/qqbot-connector';
|
|
2
|
+
|
|
3
|
+
export class QqQrAuth {
|
|
4
|
+
#start;
|
|
5
|
+
#source;
|
|
6
|
+
|
|
7
|
+
constructor({ start = startQrConnect, source = 'deepseek-harness' } = {}) {
|
|
8
|
+
if (typeof start !== 'function') throw new TypeError('QQ QR connector is required');
|
|
9
|
+
this.#start = start;
|
|
10
|
+
this.#source = source;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
start(callbacks, { signal } = {}) {
|
|
14
|
+
if (!callbacks || typeof callbacks.onSuccess !== 'function'
|
|
15
|
+
|| typeof callbacks.onFailure !== 'function') {
|
|
16
|
+
throw new TypeError('QQ QR callbacks are required');
|
|
17
|
+
}
|
|
18
|
+
return this.#start(callbacks, {
|
|
19
|
+
displayQrCodeToConsole: false,
|
|
20
|
+
source: this.#source,
|
|
21
|
+
signal,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
|
|
5
|
+
|
|
6
|
+
function normalizeState(value) {
|
|
7
|
+
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
|
8
|
+
const sessions = {};
|
|
9
|
+
if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
|
|
10
|
+
for (const [key, sessionId] of Object.entries(value.sessions)) {
|
|
11
|
+
if (typeof key === 'string' && typeof sessionId === 'string' && sessionId) {
|
|
12
|
+
sessions[key] = sessionId;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
version: 1,
|
|
18
|
+
sessions,
|
|
19
|
+
seenMessageIds: Array.isArray(value.seenMessageIds)
|
|
20
|
+
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
|
21
|
+
: [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class QqStateStore {
|
|
26
|
+
#path;
|
|
27
|
+
#state = structuredClone(EMPTY_STATE);
|
|
28
|
+
#writeQueue = Promise.resolve();
|
|
29
|
+
|
|
30
|
+
constructor(path) {
|
|
31
|
+
this.#path = path;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async load() {
|
|
35
|
+
try {
|
|
36
|
+
this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
39
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
40
|
+
await this.#persist();
|
|
41
|
+
}
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
sessionFor(key) {
|
|
46
|
+
return this.#state.sessions[key] ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async setSession(key, sessionId) {
|
|
50
|
+
this.#state.sessions[key] = sessionId;
|
|
51
|
+
await this.#persist();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async clearSession(key) {
|
|
55
|
+
delete this.#state.sessions[key];
|
|
56
|
+
await this.#persist();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
hasSeen(messageId) {
|
|
60
|
+
return this.#state.seenMessageIds.includes(messageId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async markSeen(messageId) {
|
|
64
|
+
if (this.hasSeen(messageId)) return;
|
|
65
|
+
this.#state.seenMessageIds.push(messageId);
|
|
66
|
+
if (this.#state.seenMessageIds.length > 1_000) {
|
|
67
|
+
this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
|
|
68
|
+
}
|
|
69
|
+
await this.#persist();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
snapshot() {
|
|
73
|
+
return structuredClone(this.#state);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async remove() {
|
|
77
|
+
try {
|
|
78
|
+
await unlink(this.#path);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
81
|
+
}
|
|
82
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async #persist() {
|
|
86
|
+
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
|
|
87
|
+
const operation = this.#writeQueue.then(async () => {
|
|
88
|
+
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
89
|
+
const temporary = `${this.#path}.tmp`;
|
|
90
|
+
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
91
|
+
await rename(temporary, this.#path);
|
|
92
|
+
});
|
|
93
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
94
|
+
await operation;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null });
|
|
5
|
+
|
|
6
|
+
function normalizeState(value) {
|
|
7
|
+
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
|
8
|
+
const sessions = {};
|
|
9
|
+
if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
|
|
10
|
+
for (const [key, sessionId] of Object.entries(value.sessions)) {
|
|
11
|
+
if (typeof key === 'string' && key && typeof sessionId === 'string' && sessionId) {
|
|
12
|
+
sessions[key] = sessionId;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
version: 1,
|
|
18
|
+
sessions,
|
|
19
|
+
seenMessageIds: Array.isArray(value.seenMessageIds)
|
|
20
|
+
? value.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-1_000)
|
|
21
|
+
: [],
|
|
22
|
+
cursor: Number.isSafeInteger(value.cursor) && value.cursor >= 0 ? value.cursor : null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ConversationStateStore {
|
|
27
|
+
#path;
|
|
28
|
+
#state = structuredClone(EMPTY_STATE);
|
|
29
|
+
#writeQueue = Promise.resolve();
|
|
30
|
+
|
|
31
|
+
constructor(path) {
|
|
32
|
+
this.#path = path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async load() {
|
|
36
|
+
try {
|
|
37
|
+
this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
40
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
41
|
+
await this.#persist();
|
|
42
|
+
}
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
sessionFor(key) {
|
|
47
|
+
return this.#state.sessions[key] ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async setSession(key, sessionId) {
|
|
51
|
+
this.#state.sessions[key] = sessionId;
|
|
52
|
+
await this.#persist();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async clearSession(key) {
|
|
56
|
+
delete this.#state.sessions[key];
|
|
57
|
+
await this.#persist();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
hasSeen(messageId) {
|
|
61
|
+
return this.#state.seenMessageIds.includes(messageId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async markSeen(messageId) {
|
|
65
|
+
if (this.hasSeen(messageId)) return;
|
|
66
|
+
this.#state.seenMessageIds.push(messageId);
|
|
67
|
+
if (this.#state.seenMessageIds.length > 1_000) {
|
|
68
|
+
this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
|
|
69
|
+
}
|
|
70
|
+
await this.#persist();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cursor() {
|
|
74
|
+
return this.#state.cursor;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async setCursor(cursor) {
|
|
78
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('Invalid update cursor');
|
|
79
|
+
this.#state.cursor = cursor;
|
|
80
|
+
await this.#persist();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
snapshot() {
|
|
84
|
+
return structuredClone(this.#state);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async remove() {
|
|
88
|
+
try {
|
|
89
|
+
await unlink(this.#path);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
92
|
+
}
|
|
93
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #persist() {
|
|
97
|
+
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
|
|
98
|
+
const operation = this.#writeQueue.then(async () => {
|
|
99
|
+
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
100
|
+
const temporary = `${this.#path}.tmp`;
|
|
101
|
+
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
102
|
+
await rename(temporary, this.#path);
|
|
103
|
+
});
|
|
104
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
105
|
+
await operation;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function splitMessageText(value, limit) {
|
|
2
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
3
|
+
if (!text) return [];
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let remaining = text;
|
|
6
|
+
while (remaining.length > limit) {
|
|
7
|
+
let cut = remaining.lastIndexOf('\n', limit);
|
|
8
|
+
if (cut < Math.floor(limit * 0.55)) cut = remaining.lastIndexOf(' ', limit);
|
|
9
|
+
if (cut < Math.floor(limit * 0.55)) cut = limit;
|
|
10
|
+
chunks.push(remaining.slice(0, cut).trim());
|
|
11
|
+
remaining = remaining.slice(cut).trimStart();
|
|
12
|
+
}
|
|
13
|
+
if (remaining) chunks.push(remaining);
|
|
14
|
+
return chunks;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createEditableMessageStream({
|
|
18
|
+
initialText = '正在处理…',
|
|
19
|
+
limit,
|
|
20
|
+
updateIntervalMs = 800,
|
|
21
|
+
create,
|
|
22
|
+
edit,
|
|
23
|
+
sendRemainder,
|
|
24
|
+
logger = console,
|
|
25
|
+
}) {
|
|
26
|
+
let messageId;
|
|
27
|
+
let pending = null;
|
|
28
|
+
let timer = null;
|
|
29
|
+
let inFlight = null;
|
|
30
|
+
let closed = false;
|
|
31
|
+
let lastSent = initialText;
|
|
32
|
+
|
|
33
|
+
const schedule = () => {
|
|
34
|
+
if (closed || timer !== null || inFlight || !pending) return;
|
|
35
|
+
timer = setTimeout(() => {
|
|
36
|
+
timer = null;
|
|
37
|
+
const text = pending;
|
|
38
|
+
pending = null;
|
|
39
|
+
const next = splitMessageText(text, limit)[0] ?? initialText;
|
|
40
|
+
inFlight = Promise.resolve(next === lastSent ? undefined : edit(messageId, next))
|
|
41
|
+
.then(() => { lastSent = next; })
|
|
42
|
+
.catch((error) => logger.warn?.('[dsh-im] streamed message update failed:', error))
|
|
43
|
+
.finally(() => {
|
|
44
|
+
inFlight = null;
|
|
45
|
+
schedule();
|
|
46
|
+
});
|
|
47
|
+
}, updateIntervalMs);
|
|
48
|
+
timer?.unref?.();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
async start() {
|
|
53
|
+
messageId = await create(initialText);
|
|
54
|
+
return this;
|
|
55
|
+
},
|
|
56
|
+
update(text) {
|
|
57
|
+
if (closed || typeof text !== 'string' || !text.trim()) return;
|
|
58
|
+
pending = text;
|
|
59
|
+
schedule();
|
|
60
|
+
},
|
|
61
|
+
async finish(text) {
|
|
62
|
+
if (closed) throw new Error('Message stream is already closed');
|
|
63
|
+
closed = true;
|
|
64
|
+
if (timer !== null) clearTimeout(timer);
|
|
65
|
+
timer = null;
|
|
66
|
+
pending = null;
|
|
67
|
+
await inFlight?.catch(() => undefined);
|
|
68
|
+
const chunks = splitMessageText(text, limit);
|
|
69
|
+
const first = chunks[0] ?? '处理完成。';
|
|
70
|
+
if (first !== lastSent) await edit(messageId, first);
|
|
71
|
+
lastSent = first;
|
|
72
|
+
for (const chunk of chunks.slice(1)) await sendRemainder(chunk);
|
|
73
|
+
},
|
|
74
|
+
cancel() {
|
|
75
|
+
closed = true;
|
|
76
|
+
pending = null;
|
|
77
|
+
if (timer !== null) clearTimeout(timer);
|
|
78
|
+
timer = null;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
function cleanText(value) {
|
|
2
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function createTextBridgeStatus() {
|
|
6
|
+
return {
|
|
7
|
+
messagesReceived: 0,
|
|
8
|
+
messagesReplied: 0,
|
|
9
|
+
messagesRejected: 0,
|
|
10
|
+
lastMessageAt: null,
|
|
11
|
+
lastReplyAt: null,
|
|
12
|
+
lastRejectedAt: null,
|
|
13
|
+
lastError: null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TextHarnessBridge {
|
|
18
|
+
#descriptor;
|
|
19
|
+
#bot;
|
|
20
|
+
#harness;
|
|
21
|
+
#state;
|
|
22
|
+
#status;
|
|
23
|
+
#logger;
|
|
24
|
+
#replyTimeoutMs;
|
|
25
|
+
#queues = new Map();
|
|
26
|
+
|
|
27
|
+
constructor({
|
|
28
|
+
descriptor,
|
|
29
|
+
bot,
|
|
30
|
+
harness,
|
|
31
|
+
state,
|
|
32
|
+
status = createTextBridgeStatus(),
|
|
33
|
+
logger = console,
|
|
34
|
+
replyTimeoutMs = 600_000,
|
|
35
|
+
}) {
|
|
36
|
+
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
|
|
37
|
+
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
|
|
38
|
+
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
|
39
|
+
this.#descriptor = descriptor;
|
|
40
|
+
this.#bot = bot;
|
|
41
|
+
this.#harness = harness;
|
|
42
|
+
this.#state = state;
|
|
43
|
+
this.#status = status;
|
|
44
|
+
this.#logger = logger;
|
|
45
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get status() {
|
|
49
|
+
return structuredClone(this.#status);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
accept(message) {
|
|
53
|
+
const conversationId = cleanText(message?.conversationId);
|
|
54
|
+
const kind = message?.kind === 'group' ? 'group' : 'direct';
|
|
55
|
+
const key = `${kind}:${conversationId}`;
|
|
56
|
+
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
57
|
+
const current = previous
|
|
58
|
+
.catch(() => undefined)
|
|
59
|
+
.then(() => this.#process({ ...message, kind, conversationId }))
|
|
60
|
+
.finally(() => {
|
|
61
|
+
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
62
|
+
});
|
|
63
|
+
this.#queues.set(key, current);
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async waitForIdle() {
|
|
68
|
+
await Promise.allSettled([...this.#queues.values()]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #process(message) {
|
|
72
|
+
const messageId = cleanText(message.messageId);
|
|
73
|
+
const senderId = cleanText(message.senderId);
|
|
74
|
+
if (!messageId || !senderId || !message.conversationId || message.senderIsBot === true) return;
|
|
75
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
76
|
+
|
|
77
|
+
this.#status.messagesReceived += 1;
|
|
78
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
79
|
+
if (message.kind === 'group' && message.addressed !== true) {
|
|
80
|
+
this.#status.messagesRejected += 1;
|
|
81
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const target = message.replyTarget;
|
|
86
|
+
const text = cleanText(message.content);
|
|
87
|
+
try {
|
|
88
|
+
if (!text) {
|
|
89
|
+
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
|
90
|
+
await this.#state.markSeen(messageId);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const command = text.toLowerCase();
|
|
94
|
+
if (command === '/help') {
|
|
95
|
+
await this.#bot.sendText(target, [
|
|
96
|
+
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
|
97
|
+
'',
|
|
98
|
+
'直接发送文字即可继续当前会话。',
|
|
99
|
+
'/new 开启一个全新会话',
|
|
100
|
+
'/status 检查连接状态',
|
|
101
|
+
'/help 显示本帮助',
|
|
102
|
+
].join('\n'));
|
|
103
|
+
await this.#state.markSeen(messageId);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (command === '/status') {
|
|
107
|
+
await this.#harness.ensureRunning();
|
|
108
|
+
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
|
109
|
+
await this.#state.markSeen(messageId);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const conversationKey = `${message.kind}:${message.conversationId}`;
|
|
113
|
+
if (command === '/new') {
|
|
114
|
+
await this.#state.clearSession(conversationKey);
|
|
115
|
+
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
116
|
+
await this.#state.markSeen(messageId);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let sessionId = this.#state.sessionFor(conversationKey);
|
|
121
|
+
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
|
|
122
|
+
sessionId = await this.#harness.createSession();
|
|
123
|
+
await this.#state.setSession(conversationKey, sessionId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await this.#bot.sendTyping?.(target).catch((error) => {
|
|
127
|
+
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
|
|
128
|
+
});
|
|
129
|
+
let stream = null;
|
|
130
|
+
let streamFinished = false;
|
|
131
|
+
if (typeof this.#bot.openStream === 'function') {
|
|
132
|
+
try {
|
|
133
|
+
stream = await this.#bot.openStream(target);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.#logger.warn?.(
|
|
136
|
+
`[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
|
|
137
|
+
error,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const answer = await this.#harness.ask(sessionId, text, {
|
|
142
|
+
timeoutMs: this.#replyTimeoutMs,
|
|
143
|
+
onUpdate: stream ? async (update) => {
|
|
144
|
+
const progress = update.type === 'text' ? update.text
|
|
145
|
+
: update.type === 'tool' ? `正在使用${update.name}…` : update.text;
|
|
146
|
+
if (progress) await stream.update(progress);
|
|
147
|
+
} : undefined,
|
|
148
|
+
});
|
|
149
|
+
if (stream) {
|
|
150
|
+
try {
|
|
151
|
+
await stream.finish(answer);
|
|
152
|
+
streamFinished = true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
stream.cancel?.();
|
|
155
|
+
this.#logger.warn?.(
|
|
156
|
+
`[dsh-im:${this.#descriptor.key}] streamed reply finalization failed; using text:`,
|
|
157
|
+
error,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!streamFinished) await this.#bot.sendText(target, answer);
|
|
162
|
+
await this.#state.markSeen(messageId);
|
|
163
|
+
this.#status.messagesReplied += 1;
|
|
164
|
+
this.#status.lastReplyAt = new Date().toISOString();
|
|
165
|
+
this.#status.lastError = null;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
168
|
+
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
|
169
|
+
try {
|
|
170
|
+
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
|
171
|
+
await this.#state.markSeen(messageId);
|
|
172
|
+
} catch (sendError) {
|
|
173
|
+
this.#logger.error?.(
|
|
174
|
+
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
|
|
175
|
+
sendError,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|