@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,454 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
2
|
+
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
|
3
|
+
import {
|
|
4
|
+
FEISHU_ENDPOINTS,
|
|
5
|
+
FEISHU_RPC_CHANNEL,
|
|
6
|
+
} from '../../../client/channels/feishu/api.js';
|
|
7
|
+
|
|
8
|
+
export { FEISHU_ENDPOINTS, FEISHU_RPC_CHANNEL };
|
|
9
|
+
export const FEISHU_MULTI_ENDPOINTS = Object.freeze({
|
|
10
|
+
reconnectBot: 'bot.reconnect',
|
|
11
|
+
disconnectBot: 'bot.disconnect',
|
|
12
|
+
deleteBot: 'bot.delete',
|
|
13
|
+
});
|
|
14
|
+
export const FEISHU_RPC_ENDPOINTS = Object.freeze([
|
|
15
|
+
...new Set([...Object.values(FEISHU_ENDPOINTS), ...Object.values(FEISHU_MULTI_ENDPOINTS)]),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const REGISTRATION_STATES = new Set([
|
|
19
|
+
'idle', 'starting', 'qr_ready', 'polling', 'slow_down',
|
|
20
|
+
'domain_switched', 'saving', 'succeeded', 'expired', 'cancelled', 'error',
|
|
21
|
+
]);
|
|
22
|
+
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
23
|
+
|
|
24
|
+
const PUBLIC_ERROR_MESSAGES = Object.freeze({
|
|
25
|
+
abort: 'Registration was cancelled.',
|
|
26
|
+
access_denied: 'Registration was denied.',
|
|
27
|
+
expired_token: 'The registration QR code expired.',
|
|
28
|
+
invalid_credentials: 'Feishu returned invalid app credentials.',
|
|
29
|
+
credentials_callback_failed: 'Unable to activate the Feishu connection.',
|
|
30
|
+
registration_failed: 'Unable to register the Feishu app.',
|
|
31
|
+
connection_failed: 'The bot was created, but its connection could not be started.',
|
|
32
|
+
credential_removal_failed: 'Unable to remove the Feishu credentials.',
|
|
33
|
+
state_cleanup_failed: 'Unable to remove the bot session data. Please retry.',
|
|
34
|
+
deletion_pending: 'Bot deletion is incomplete. Retry removal to finish cleanup.',
|
|
35
|
+
missing_credentials: 'The bot credentials are missing. Delete it and scan again.',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const POLL_STATUS_BY_REGISTRATION = Object.freeze({
|
|
39
|
+
idle: 'pending', starting: 'pending', qr_ready: 'pending', polling: 'pending',
|
|
40
|
+
slow_down: 'pending', domain_switched: 'pending', saving: 'connecting',
|
|
41
|
+
succeeded: 'connected', expired: 'expired', cancelled: 'failed', error: 'failed',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function isPlainObject(value) {
|
|
45
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
46
|
+
const prototype = Object.getPrototypeOf(value);
|
|
47
|
+
return prototype === Object.prototype || prototype === null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasOnlyKeys(value, allowed) {
|
|
51
|
+
return isPlainObject(value)
|
|
52
|
+
&& Reflect.ownKeys(value).every((key) => typeof key === 'string' && allowed.has(key));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function finiteNumber(value) {
|
|
56
|
+
return Number.isFinite(value) ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeOpaqueId(value) {
|
|
60
|
+
return typeof value === 'string' && SAFE_ID.test(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validCredential(value, maxLength) {
|
|
64
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function publicError(error) {
|
|
68
|
+
if (!error || typeof error !== 'object') return null;
|
|
69
|
+
const code = typeof error.code === 'string' && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code)
|
|
70
|
+
? error.code
|
|
71
|
+
: 'registration_failed';
|
|
72
|
+
return { code, message: PUBLIC_ERROR_MESSAGES[code] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function publicRegistration(registration) {
|
|
76
|
+
if (!registration || typeof registration !== 'object') return { state: 'idle', attempt: 0 };
|
|
77
|
+
const state = REGISTRATION_STATES.has(registration.state) ? registration.state : 'error';
|
|
78
|
+
const attempt = safeOpaqueId(registration.attempt)
|
|
79
|
+
? registration.attempt
|
|
80
|
+
: (finiteNumber(registration.attempt) ?? 0);
|
|
81
|
+
const result = { state, attempt };
|
|
82
|
+
const updatedAt = finiteNumber(registration.updatedAt);
|
|
83
|
+
const expiresAt = finiteNumber(registration.expiresAt);
|
|
84
|
+
const remainingSeconds = finiteNumber(registration.remainingSeconds);
|
|
85
|
+
const pollIntervalSeconds = finiteNumber(registration.pollIntervalSeconds);
|
|
86
|
+
if (updatedAt !== undefined) result.updatedAt = updatedAt;
|
|
87
|
+
if (typeof registration.qrCodeUrl === 'string' && registration.qrCodeUrl.length > 0) {
|
|
88
|
+
result.qrCodeUrl = registration.qrCodeUrl;
|
|
89
|
+
}
|
|
90
|
+
if (expiresAt !== undefined) result.expiresAt = expiresAt;
|
|
91
|
+
if (remainingSeconds !== undefined) result.remainingSeconds = remainingSeconds;
|
|
92
|
+
if (pollIntervalSeconds !== undefined) result.pollIntervalSeconds = pollIntervalSeconds;
|
|
93
|
+
if (safeOpaqueId(registration.botId)) result.botId = registration.botId;
|
|
94
|
+
const error = publicError(registration.error);
|
|
95
|
+
if (error) result.error = error;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function connectionFacts(connection) {
|
|
100
|
+
const source = connection && typeof connection === 'object' ? connection : {};
|
|
101
|
+
const connected = source.connected === true
|
|
102
|
+
|| (source.ready === true
|
|
103
|
+
&& source.feishuLongConnectionState === 'connected'
|
|
104
|
+
&& source.harnessReachable === true);
|
|
105
|
+
return {
|
|
106
|
+
connected,
|
|
107
|
+
ready: source.ready === true,
|
|
108
|
+
harnessReachable: source.harnessReachable === true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function publicBot(bot) {
|
|
113
|
+
const source = bot && typeof bot === 'object' ? bot : {};
|
|
114
|
+
const result = {
|
|
115
|
+
name: typeof source.name === 'string' && source.name.length > 0 ? source.name : '飞书机器人',
|
|
116
|
+
};
|
|
117
|
+
if (typeof source.avatarUrl === 'string') result.avatarUrl = source.avatarUrl;
|
|
118
|
+
if (typeof source.appIdMasked === 'string') result.appIdMasked = source.appIdMasked;
|
|
119
|
+
if (typeof source.tenantName === 'string') result.tenantName = source.tenantName;
|
|
120
|
+
if (source.domain === 'feishu' || source.domain === 'lark') result.domain = source.domain;
|
|
121
|
+
if (typeof source.activated === 'boolean' || typeof source.activated === 'number') {
|
|
122
|
+
result.activated = source.activated;
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function publicHealth(status, connected) {
|
|
128
|
+
if (connected) return { status: 'healthy', summary: '长连接运行正常', lastCheckedAt: Date.now() };
|
|
129
|
+
if (status?.configured === true) {
|
|
130
|
+
return { status: 'offline', summary: '机器人尚未连接', lastCheckedAt: Date.now() };
|
|
131
|
+
}
|
|
132
|
+
return { status: 'offline', summary: '尚未接入飞书机器人', lastCheckedAt: Date.now() };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function connectionState(status, registration, connected) {
|
|
136
|
+
if (connected) return 'connected';
|
|
137
|
+
if (status?.phase === 'error' || registration.state === 'error') return 'error';
|
|
138
|
+
if (status?.phase === 'connecting' || registration.state === 'saving') return 'connecting';
|
|
139
|
+
if (status?.phase === 'registering'
|
|
140
|
+
|| ['starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched'].includes(registration.state)) {
|
|
141
|
+
return 'provisioning';
|
|
142
|
+
}
|
|
143
|
+
return 'disconnected';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function qrCodeDataUrl(verificationUrl) {
|
|
147
|
+
return QRCode.toDataURL(verificationUrl, {
|
|
148
|
+
errorCorrectionLevel: 'M', margin: 1, width: 320, type: 'image/png',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function publicProvisioning(registration, encodeQr) {
|
|
153
|
+
if (!registration.qrCodeUrl) return undefined;
|
|
154
|
+
return {
|
|
155
|
+
attemptId: String(registration.attempt),
|
|
156
|
+
verificationUrl: registration.qrCodeUrl,
|
|
157
|
+
qrCodeDataUrl: await encodeQr(registration.qrCodeUrl),
|
|
158
|
+
expiresAt: registration.expiresAt ?? Date.now() + (5 * 60_000),
|
|
159
|
+
pollIntervalMs: Math.max(800, Math.min(10_000, (registration.pollIntervalSeconds ?? 1.8) * 1000)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function publicBotEntry(entry) {
|
|
164
|
+
const source = entry && typeof entry === 'object' ? entry : {};
|
|
165
|
+
if (!safeOpaqueId(source.botId)) return null;
|
|
166
|
+
const facts = connectionFacts(source.connection);
|
|
167
|
+
const connected = source.connected === true || facts.connected;
|
|
168
|
+
const registration = { state: 'idle' };
|
|
169
|
+
const result = {
|
|
170
|
+
botId: source.botId,
|
|
171
|
+
state: connectionState(source, registration, connected),
|
|
172
|
+
connected,
|
|
173
|
+
configured: source.configured === true,
|
|
174
|
+
bot: publicBot(source.bot),
|
|
175
|
+
health: publicHealth(source, connected),
|
|
176
|
+
};
|
|
177
|
+
const error = publicError(source.error);
|
|
178
|
+
if (error) result.error = error;
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Exact redacted browser contract consumed by the Feishu settings client. */
|
|
183
|
+
export async function toPublicFeishuStatus(status, { encodeQr = qrCodeDataUrl } = {}) {
|
|
184
|
+
const source = status && typeof status === 'object' ? status : {};
|
|
185
|
+
const registration = publicRegistration(source.registration);
|
|
186
|
+
const facts = connectionFacts(source.connection);
|
|
187
|
+
const connected = source.connected === true || facts.connected;
|
|
188
|
+
const provisioning = await publicProvisioning(registration, encodeQr);
|
|
189
|
+
const error = publicError(source.error) ?? registration.error ?? null;
|
|
190
|
+
const bots = Array.isArray(source.bots)
|
|
191
|
+
? source.bots.map(publicBotEntry).filter(Boolean)
|
|
192
|
+
: [];
|
|
193
|
+
const snapshot = {
|
|
194
|
+
schemaVersion: source.schemaVersion === 2 ? 2 : 1,
|
|
195
|
+
revision: Number.isSafeInteger(source.revision) && source.revision >= 0 ? source.revision : 0,
|
|
196
|
+
state: connectionState(source, registration, connected),
|
|
197
|
+
connected,
|
|
198
|
+
configured: source.configured === true,
|
|
199
|
+
bot: publicBot(source.bot),
|
|
200
|
+
health: publicHealth(source, connected),
|
|
201
|
+
bots,
|
|
202
|
+
totals: {
|
|
203
|
+
configured: bots.length || (source.configured === true ? 1 : 0),
|
|
204
|
+
connected: bots.length ? bots.filter((bot) => bot.connected).length : (connected ? 1 : 0),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
if (provisioning) snapshot.provisioning = provisioning;
|
|
208
|
+
if (error) snapshot.error = error;
|
|
209
|
+
return snapshot;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function badRequest(message) {
|
|
213
|
+
return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cancelled() {
|
|
217
|
+
return { ok: false, error: { code: 'cancelled', message: 'The Feishu request was cancelled.', details: {} } };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function internalFailure() {
|
|
221
|
+
return { ok: false, error: { code: 'internal', message: 'The Feishu integration operation failed.', details: {} } };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validPayload(endpoint, payload) {
|
|
225
|
+
if (endpoint === FEISHU_ENDPOINTS.status) {
|
|
226
|
+
return hasOnlyKeys(payload, new Set()) ? null : 'This endpoint accepts an empty payload only.';
|
|
227
|
+
}
|
|
228
|
+
if (endpoint === FEISHU_ENDPOINTS.testConnection) {
|
|
229
|
+
return hasOnlyKeys(payload, new Set()) ? null : 'This endpoint accepts an empty payload only.';
|
|
230
|
+
}
|
|
231
|
+
if (endpoint === FEISHU_ENDPOINTS.beginProvisioning) {
|
|
232
|
+
if (!hasOnlyKeys(payload, new Set(['locale', 'replaceAttemptId']))) {
|
|
233
|
+
return 'Provisioning accepts locale and replaceAttemptId only.';
|
|
234
|
+
}
|
|
235
|
+
if (payload.locale !== undefined && payload.locale !== 'zh-CN') return 'The provisioning locale must be zh-CN.';
|
|
236
|
+
if (payload.replaceAttemptId !== undefined && !safeOpaqueId(payload.replaceAttemptId)) {
|
|
237
|
+
return 'replaceAttemptId must be a valid opaque id.';
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
|
242
|
+
return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
|
|
243
|
+
&& validCredential(payload.appId, 256)
|
|
244
|
+
&& validCredential(payload.appSecret, 1024)
|
|
245
|
+
? null
|
|
246
|
+
: 'Credential binding requires App ID and App Secret.';
|
|
247
|
+
}
|
|
248
|
+
if (endpoint === FEISHU_ENDPOINTS.pollProvisioning
|
|
249
|
+
|| endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
|
250
|
+
return hasOnlyKeys(payload, new Set(['attemptId'])) && safeOpaqueId(payload.attemptId)
|
|
251
|
+
? null
|
|
252
|
+
: 'A single valid attemptId is required.';
|
|
253
|
+
}
|
|
254
|
+
if (endpoint === FEISHU_ENDPOINTS.disconnect) {
|
|
255
|
+
return hasOnlyKeys(payload, new Set(['removeCredentials'])) && payload.removeCredentials === true
|
|
256
|
+
? null
|
|
257
|
+
: 'Disconnect requires removeCredentials=true.';
|
|
258
|
+
}
|
|
259
|
+
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot
|
|
260
|
+
|| endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
|
|
261
|
+
return hasOnlyKeys(payload, new Set(['botId'])) && safeOpaqueId(payload.botId)
|
|
262
|
+
? null
|
|
263
|
+
: 'A single valid botId is required.';
|
|
264
|
+
}
|
|
265
|
+
if (endpoint === FEISHU_MULTI_ENDPOINTS.deleteBot) {
|
|
266
|
+
return hasOnlyKeys(payload, new Set(['botId', 'confirm']))
|
|
267
|
+
&& safeOpaqueId(payload.botId) && payload.confirm === true
|
|
268
|
+
? null
|
|
269
|
+
: 'Deleting a bot requires a valid botId and confirm=true.';
|
|
270
|
+
}
|
|
271
|
+
return 'Unknown Feishu endpoint.';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function abortableDelay(milliseconds, signal) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
if (signal?.aborted) {
|
|
277
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const timer = setTimeout(done, milliseconds);
|
|
281
|
+
timer.unref?.();
|
|
282
|
+
function done() {
|
|
283
|
+
signal?.removeEventListener('abort', aborted);
|
|
284
|
+
resolve();
|
|
285
|
+
}
|
|
286
|
+
function aborted() {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
289
|
+
}
|
|
290
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function statusForRegistration(controller, attemptId) {
|
|
295
|
+
if (typeof controller.registrationStatus === 'function') {
|
|
296
|
+
return controller.registrationStatus(attemptId);
|
|
297
|
+
}
|
|
298
|
+
return controller.status();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function waitForQr(controller, initial, attemptId, signal) {
|
|
302
|
+
let current = initial;
|
|
303
|
+
const deadline = Date.now() + 15_000;
|
|
304
|
+
for (;;) {
|
|
305
|
+
const registration = publicRegistration(current?.registration);
|
|
306
|
+
if (registration.qrCodeUrl) return current;
|
|
307
|
+
if (['error', 'expired', 'cancelled'].includes(registration.state)) {
|
|
308
|
+
throw new Error('Provisioning stopped before the QR code was ready.');
|
|
309
|
+
}
|
|
310
|
+
if (Date.now() >= deadline) throw new Error('Provisioning QR code timed out.');
|
|
311
|
+
await abortableDelay(50, signal);
|
|
312
|
+
current = await statusForRegistration(controller, attemptId);
|
|
313
|
+
if (!current) throw new Error('The provisioning attempt is no longer active.');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function sameAttempt(status, attemptId) {
|
|
318
|
+
return String(publicRegistration(status?.registration).attempt) === attemptId;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function pollStatus(status) {
|
|
322
|
+
const registration = publicRegistration(status?.registration);
|
|
323
|
+
if (registration.state === 'succeeded') {
|
|
324
|
+
const connected = registration.botId
|
|
325
|
+
&& (status?.connected === true || connectionFacts(status?.connection).connected);
|
|
326
|
+
return connected ? 'connected' : 'connecting';
|
|
327
|
+
}
|
|
328
|
+
return POLL_STATUS_BY_REGISTRATION[registration.state] ?? 'failed';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function assertController(controller) {
|
|
332
|
+
if (!controller
|
|
333
|
+
|| typeof controller.status !== 'function'
|
|
334
|
+
|| typeof controller.startRegistration !== 'function'
|
|
335
|
+
|| typeof controller.cancelRegistration !== 'function'
|
|
336
|
+
|| typeof controller.disconnect !== 'function') {
|
|
337
|
+
throw new TypeError('A Feishu controller with status/start/cancel/disconnect is required');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** DSH rc.6 handler: (endpoint, payload, signal) => Promise<RpcResult>. */
|
|
342
|
+
export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) {
|
|
343
|
+
assertController(controller);
|
|
344
|
+
const qrCache = new Map();
|
|
345
|
+
const attemptQr = new Map();
|
|
346
|
+
const cachedEncodeQr = (url) => {
|
|
347
|
+
let encoded = qrCache.get(url);
|
|
348
|
+
if (!encoded) {
|
|
349
|
+
if (qrCache.size >= 32) qrCache.delete(qrCache.keys().next().value);
|
|
350
|
+
encoded = Promise.resolve().then(() => encodeQr(url));
|
|
351
|
+
qrCache.set(url, encoded);
|
|
352
|
+
}
|
|
353
|
+
return encoded;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
return async (endpoint, payload, signal) => {
|
|
357
|
+
if (signal?.aborted) return cancelled();
|
|
358
|
+
if (!FEISHU_RPC_ENDPOINTS.includes(endpoint)) return badRequest('Unknown Feishu endpoint.');
|
|
359
|
+
const payloadFailure = validPayload(endpoint, payload);
|
|
360
|
+
if (payloadFailure) return badRequest(payloadFailure);
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
let value;
|
|
364
|
+
if (endpoint === FEISHU_ENDPOINTS.status) {
|
|
365
|
+
value = await toPublicFeishuStatus(await controller.status(), { encodeQr: cachedEncodeQr });
|
|
366
|
+
} else if (endpoint === FEISHU_ENDPOINTS.beginProvisioning) {
|
|
367
|
+
if (payload.replaceAttemptId) {
|
|
368
|
+
await controller.cancelRegistration(payload.replaceAttemptId);
|
|
369
|
+
}
|
|
370
|
+
const started = await controller.startRegistration({ locale: payload.locale });
|
|
371
|
+
const attemptId = String(publicRegistration(started?.registration).attempt);
|
|
372
|
+
const ready = await waitForQr(controller, started, attemptId, signal);
|
|
373
|
+
value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning;
|
|
374
|
+
if (!value) throw new Error('Provisioning did not produce a QR code.');
|
|
375
|
+
attemptQr.set(attemptId, value.verificationUrl);
|
|
376
|
+
} else if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) {
|
|
377
|
+
const current = await statusForRegistration(controller, payload.attemptId);
|
|
378
|
+
if (!current || !sameAttempt(current, payload.attemptId)) {
|
|
379
|
+
return badRequest('The provisioning attempt is no longer active.');
|
|
380
|
+
}
|
|
381
|
+
const registration = publicRegistration(current.registration);
|
|
382
|
+
const connection = await toPublicFeishuStatus(current, { encodeQr: cachedEncodeQr });
|
|
383
|
+
value = {
|
|
384
|
+
status: pollStatus(current),
|
|
385
|
+
...(registration.botId ? { botId: registration.botId } : {}),
|
|
386
|
+
...(connection.provisioning ? { provisioning: connection.provisioning } : {}),
|
|
387
|
+
...(registration.botId && connection.connected ? { connection } : {}),
|
|
388
|
+
...(connection.error ? { message: connection.error.message } : {}),
|
|
389
|
+
};
|
|
390
|
+
if (['connected', 'expired', 'failed'].includes(value.status)) {
|
|
391
|
+
const url = attemptQr.get(payload.attemptId);
|
|
392
|
+
if (url) qrCache.delete(url);
|
|
393
|
+
attemptQr.delete(payload.attemptId);
|
|
394
|
+
}
|
|
395
|
+
} else if (endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
|
396
|
+
const current = await statusForRegistration(controller, payload.attemptId);
|
|
397
|
+
if (!current || !sameAttempt(current, payload.attemptId)) {
|
|
398
|
+
return badRequest('The provisioning attempt is no longer active.');
|
|
399
|
+
}
|
|
400
|
+
const multi = typeof controller.registrationStatus === 'function';
|
|
401
|
+
const registration = publicRegistration(current.registration);
|
|
402
|
+
if (!multi && registration.state === 'saving') await controller.disconnect();
|
|
403
|
+
else await controller.cancelRegistration(payload.attemptId);
|
|
404
|
+
const url = attemptQr.get(payload.attemptId);
|
|
405
|
+
if (url) qrCache.delete(url);
|
|
406
|
+
attemptQr.delete(payload.attemptId);
|
|
407
|
+
value = { status: 'failed', message: 'Registration was cancelled.' };
|
|
408
|
+
} else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
|
409
|
+
if (typeof controller.bindCredentials !== 'function') {
|
|
410
|
+
throw new Error('Credential binding is unavailable');
|
|
411
|
+
}
|
|
412
|
+
value = await toPublicFeishuStatus(
|
|
413
|
+
await controller.bindCredentials(payload),
|
|
414
|
+
{ encodeQr: cachedEncodeQr },
|
|
415
|
+
);
|
|
416
|
+
} else if (endpoint === FEISHU_ENDPOINTS.testConnection) {
|
|
417
|
+
const current = await controller.status();
|
|
418
|
+
const alreadyConnected = current?.connected === true
|
|
419
|
+
|| connectionFacts(current?.connection).connected;
|
|
420
|
+
const checked = alreadyConnected || typeof controller.reconnect !== 'function'
|
|
421
|
+
? current
|
|
422
|
+
: await controller.reconnect();
|
|
423
|
+
value = await toPublicFeishuStatus(checked, { encodeQr: cachedEncodeQr });
|
|
424
|
+
} else if (endpoint === FEISHU_ENDPOINTS.disconnect) {
|
|
425
|
+
value = await toPublicFeishuStatus(await controller.disconnect(), { encodeQr: cachedEncodeQr });
|
|
426
|
+
} else if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
|
|
427
|
+
if (typeof controller.reconnectBot !== 'function') throw new Error('Multi-bot reconnect is unavailable');
|
|
428
|
+
value = await toPublicFeishuStatus(await controller.reconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
|
|
429
|
+
} else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
|
|
430
|
+
if (typeof controller.disconnectBot !== 'function') throw new Error('Multi-bot disconnect is unavailable');
|
|
431
|
+
value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
|
|
432
|
+
} else {
|
|
433
|
+
if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
|
|
434
|
+
value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
|
|
435
|
+
}
|
|
436
|
+
if (signal?.aborted) return cancelled();
|
|
437
|
+
return { ok: true, value };
|
|
438
|
+
} catch {
|
|
439
|
+
return signal?.aborted ? cancelled() : internalFailure();
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Register the `/feishu` logical channel with its configured browser authority. */
|
|
445
|
+
export function installFeishuRpc(ctx, controller, options, authority) {
|
|
446
|
+
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
|
447
|
+
throw new TypeError('DSH Host Connection RPC is required');
|
|
448
|
+
}
|
|
449
|
+
return ctx.connection.rpc.handle(
|
|
450
|
+
FEISHU_RPC_CHANNEL,
|
|
451
|
+
createFeishuRpcHandler(controller, options),
|
|
452
|
+
{ authority: resolveRpcAuthority(authority) },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
|
|
2
|
+
|
|
3
|
+
function retryDelays(value) {
|
|
4
|
+
if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS];
|
|
5
|
+
const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0);
|
|
6
|
+
return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ConnectionSupervisor {
|
|
10
|
+
#controller;
|
|
11
|
+
#harness;
|
|
12
|
+
#logger;
|
|
13
|
+
#retryDelays;
|
|
14
|
+
#healthyIntervalMs;
|
|
15
|
+
#setTimeout;
|
|
16
|
+
#clearTimeout;
|
|
17
|
+
#timer = null;
|
|
18
|
+
#running = null;
|
|
19
|
+
#retryIndex = 0;
|
|
20
|
+
#closed = false;
|
|
21
|
+
#started = false;
|
|
22
|
+
#ready;
|
|
23
|
+
#resolveReady;
|
|
24
|
+
|
|
25
|
+
constructor({
|
|
26
|
+
controller,
|
|
27
|
+
harness,
|
|
28
|
+
logger = console,
|
|
29
|
+
retryDelaysMs,
|
|
30
|
+
healthyIntervalMs = 15_000,
|
|
31
|
+
setTimeoutImpl = setTimeout,
|
|
32
|
+
clearTimeoutImpl = clearTimeout,
|
|
33
|
+
}) {
|
|
34
|
+
if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') {
|
|
35
|
+
throw new TypeError('ConnectionSupervisor requires a controller');
|
|
36
|
+
}
|
|
37
|
+
if (!harness || typeof harness.ensureRunning !== 'function') {
|
|
38
|
+
throw new TypeError('ConnectionSupervisor requires a Harness client');
|
|
39
|
+
}
|
|
40
|
+
this.#controller = controller;
|
|
41
|
+
this.#harness = harness;
|
|
42
|
+
this.#logger = logger;
|
|
43
|
+
this.#retryDelays = retryDelays(retryDelaysMs);
|
|
44
|
+
this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
|
|
45
|
+
? healthyIntervalMs : 15_000;
|
|
46
|
+
this.#setTimeout = setTimeoutImpl;
|
|
47
|
+
this.#clearTimeout = clearTimeoutImpl;
|
|
48
|
+
this.#ready = new Promise((resolve) => {
|
|
49
|
+
this.#resolveReady = resolve;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get ready() {
|
|
54
|
+
return this.#ready;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
start() {
|
|
58
|
+
if (this.#started || this.#closed) return this;
|
|
59
|
+
this.#started = true;
|
|
60
|
+
this.#schedule(0);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async close() {
|
|
65
|
+
if (this.#closed) return;
|
|
66
|
+
this.#closed = true;
|
|
67
|
+
if (this.#timer !== null) this.#clearTimeout(this.#timer);
|
|
68
|
+
this.#timer = null;
|
|
69
|
+
await this.#running?.catch(() => undefined);
|
|
70
|
+
this.#resolveReady?.(null);
|
|
71
|
+
this.#resolveReady = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#schedule(delayMs) {
|
|
75
|
+
if (this.#closed) return;
|
|
76
|
+
this.#timer = this.#setTimeout(() => {
|
|
77
|
+
this.#timer = null;
|
|
78
|
+
void this.#run();
|
|
79
|
+
}, delayMs);
|
|
80
|
+
this.#timer?.unref?.();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async #run() {
|
|
84
|
+
if (this.#closed || this.#running) return;
|
|
85
|
+
const operation = this.#reconcile();
|
|
86
|
+
this.#running = operation;
|
|
87
|
+
try {
|
|
88
|
+
await operation;
|
|
89
|
+
} finally {
|
|
90
|
+
if (this.#running === operation) this.#running = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async #reconcile() {
|
|
95
|
+
try {
|
|
96
|
+
await this.#harness.ensureRunning();
|
|
97
|
+
if (this.#closed) return;
|
|
98
|
+
const status = await this.#controller.initialize();
|
|
99
|
+
if (this.#closed) return;
|
|
100
|
+
this.#resolveReady?.(status);
|
|
101
|
+
this.#resolveReady = null;
|
|
102
|
+
const { configured, connected } = status.totals;
|
|
103
|
+
if (connected < configured) {
|
|
104
|
+
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
|
|
105
|
+
this.#retryIndex += 1;
|
|
106
|
+
this.#logger.warn?.(`[dsh-im:qq] ${connected}/${configured} bots connected; retrying in ${delay}ms`);
|
|
107
|
+
this.#schedule(delay);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.#retryIndex = 0;
|
|
111
|
+
this.#schedule(this.#healthyIntervalMs);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (this.#closed) return;
|
|
114
|
+
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
|
|
115
|
+
this.#retryIndex += 1;
|
|
116
|
+
this.#logger.warn?.(`[dsh-im:qq] connection reconciliation failed; retrying in ${delay}ms`, error);
|
|
117
|
+
this.#schedule(delay);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createConnectionSupervisor(options) {
|
|
123
|
+
return new ConnectionSupervisor(options);
|
|
124
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createProductionController } from './production.mjs';
|
|
2
|
+
import { installQqRpc } from './rpc.mjs';
|
|
3
|
+
|
|
4
|
+
export const name = 'dsh-im-qq-host';
|
|
5
|
+
export const inject = ['connection', 'credentials', 'webServer'];
|
|
6
|
+
|
|
7
|
+
export async function apply(ctx, config = {}) {
|
|
8
|
+
if (config?.controller) {
|
|
9
|
+
return installQqRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
|
10
|
+
}
|
|
11
|
+
const production = await createProductionController(ctx, config, config.internals);
|
|
12
|
+
const disposeRpc = installQqRpc(
|
|
13
|
+
ctx,
|
|
14
|
+
production.controller,
|
|
15
|
+
config.rpcOptions,
|
|
16
|
+
config.rpcAuthority,
|
|
17
|
+
);
|
|
18
|
+
ctx.effect(() => async () => production.close(), 'dsh-im: close QQ bot connections');
|
|
19
|
+
return disposeRpc;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createQqHostPlugin(config) {
|
|
23
|
+
return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs';
|
|
27
|
+
export { createProductionController } from './production.mjs';
|
|
28
|
+
export { QQ_ENDPOINTS, QQ_RPC_CHANNEL, QQ_RPC_ENDPOINTS, createQqRpcHandler, installQqRpc } from './rpc.mjs';
|
|
29
|
+
export { QqController } from '../../../../src/channels/qq/qq-controller.mjs';
|
|
30
|
+
export { QqRuntime } from '../../../../src/channels/qq/qq-runtime.mjs';
|