@xmanrui/dsh-im 2.0.0 → 2.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/assets/screenshot-menu-card.png +0 -0
- package/lib/client.js +3 -3
- package/lib/index.js +218 -190
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/styles.js +1 -1
- package/plugin-src/client/styles.js +2 -2
- package/plugin-src/host/build.mjs +3 -0
- package/plugin-src/host/index.mjs +27 -10
- package/plugin-src/host/lark-sdk-handshake-patch.mjs +181 -0
- package/src/channels/feishu/bridge.mjs +1411 -164
- package/src/channels/feishu/feishu-cards.mjs +663 -56
- package/src/channels/feishu/feishu-runtime.mjs +129 -43
- package/src/channels/shared/control-command.mjs +2 -2
- package/src/channels/shared/harness-client.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +134 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +4 -0
- package/src/channels/shared/model-command.mjs +65 -12
- package/src/channels/shared/workspace-command.mjs +4 -4
package/package.json
CHANGED
|
@@ -450,7 +450,7 @@ const CSS = String.raw`
|
|
|
450
450
|
.bxf-connectedFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
|
|
451
451
|
.bxf-healthSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
|
452
452
|
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
|
453
|
-
.bxf-botActions { flex: none; flex-wrap:
|
|
453
|
+
.bxf-botActions { flex: none; width: 100%; flex-wrap: wrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
|
|
454
454
|
.bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
|
|
455
455
|
.bxf-botActions .bxf-repairButton { color: var(--bxf-accent); border-color: color-mix(in srgb, var(--bxf-accent) 35%, var(--dsw-alias-border-l2, #dee0e3)); }
|
|
456
456
|
.bxf-botActions .bxf-repairButton:hover:not(:disabled) { background: color-mix(in srgb, var(--bxf-accent) 7%, transparent); }
|
|
@@ -206,9 +206,9 @@ const CSS = String.raw`
|
|
|
206
206
|
.dim-directoryPickerActions button:disabled { cursor: not-allowed; opacity: .52; }
|
|
207
207
|
.dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; }
|
|
208
208
|
.dim-panel .dim-cardFooterLayout { min-width: 0; width: 100%; display: flex; flex-direction: column; align-items: stretch; gap: 9px; }
|
|
209
|
-
.dim-panel .dim-cardFooterLayout > .dim-cardActions { align-self:
|
|
209
|
+
.dim-panel .dim-cardFooterLayout > .dim-cardActions { align-self: stretch; }
|
|
210
210
|
.dim-panel .dim-cardFeedback { width: 100%; padding: 8px 10px; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font: inherit; font-size: 12px; font-weight: 400; line-height: 18px; overflow-wrap: anywhere; white-space: normal; }
|
|
211
|
-
.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap:
|
|
211
|
+
.dim-panel .dim-cardActions { flex: none; width: 100%; display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin: 0; }
|
|
212
212
|
.dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 32px; padding: 0 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }
|
|
213
213
|
.dim-panel .dim-cardActions .dim-cardAction:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
|
214
214
|
.dim-panel .dim-cardActions .dim-cardAction[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); }
|
|
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
|
|
5
5
|
import { build } from 'esbuild';
|
|
6
6
|
|
|
7
|
+
import { larkSdkHandshakePatch } from './lark-sdk-handshake-patch.mjs';
|
|
8
|
+
|
|
7
9
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
const packageRoot = resolve(sourceDirectory, '../..');
|
|
9
11
|
const outputPath = resolve(packageRoot, 'lib/index.js');
|
|
@@ -25,6 +27,7 @@ await build({
|
|
|
25
27
|
target: ['node22'],
|
|
26
28
|
mainFields: ['module', 'main'],
|
|
27
29
|
external,
|
|
30
|
+
plugins: [larkSdkHandshakePatch],
|
|
28
31
|
outfile: outputPath,
|
|
29
32
|
banner: {
|
|
30
33
|
js: [
|
|
@@ -37,6 +37,18 @@ export function createImHostPlugin(internals = {}) {
|
|
|
37
37
|
const startDiscord = internals.applyDiscord ?? applyDiscord;
|
|
38
38
|
const startOffice = internals.applyOffice ?? applyOffice;
|
|
39
39
|
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
|
|
40
|
+
const channels = [
|
|
41
|
+
['feishu', startFeishu],
|
|
42
|
+
['weixin', startWeixin],
|
|
43
|
+
['dingtalk', startDingtalk],
|
|
44
|
+
['wecom', startWecom],
|
|
45
|
+
['qq', startQq],
|
|
46
|
+
['slack', startSlack],
|
|
47
|
+
['telegram', startTelegram],
|
|
48
|
+
['discord', startDiscord],
|
|
49
|
+
['whatsapp', startWhatsapp],
|
|
50
|
+
['office', startOffice],
|
|
51
|
+
];
|
|
40
52
|
return Object.freeze({
|
|
41
53
|
name,
|
|
42
54
|
inject,
|
|
@@ -49,16 +61,21 @@ export function createImHostPlugin(internals = {}) {
|
|
|
49
61
|
} else {
|
|
50
62
|
installOutboundArtifactTool(ctx);
|
|
51
63
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const logger = typeof ctx?.logger === 'function'
|
|
65
|
+
? ctx.logger(name)
|
|
66
|
+
: (ctx?.logger ?? console);
|
|
67
|
+
const failures = [];
|
|
68
|
+
for (const [channel, start] of channels) {
|
|
69
|
+
try {
|
|
70
|
+
await start(ctx, channelConfig(config, channel));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
failures.push(error);
|
|
73
|
+
logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (failures.length === channels.length) {
|
|
77
|
+
throw new AggregateError(failures, 'dsh-im failed to activate every channel');
|
|
78
|
+
}
|
|
62
79
|
},
|
|
63
80
|
});
|
|
64
81
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const PATCHES = [
|
|
4
|
+
{
|
|
5
|
+
label: 'WSClient pending-socket state',
|
|
6
|
+
before: ` this.wsConfig = new WSConfig();
|
|
7
|
+
this.reconnectGeneration = 0;
|
|
8
|
+
this.isConnecting = false;`,
|
|
9
|
+
after: ` this.wsConfig = new WSConfig();
|
|
10
|
+
this.reconnectGeneration = 0;
|
|
11
|
+
this.pendingWsInstance = null;
|
|
12
|
+
this.isConnecting = false;`,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
label: 'WSClient pending-socket registration',
|
|
16
|
+
before: ` if (!wsInstance) {
|
|
17
|
+
return Promise.resolve(false);
|
|
18
|
+
}
|
|
19
|
+
return new Promise((resolve) => {`,
|
|
20
|
+
after: ` if (!wsInstance) {
|
|
21
|
+
return Promise.resolve(false);
|
|
22
|
+
}
|
|
23
|
+
this.pendingWsInstance = wsInstance;
|
|
24
|
+
return new Promise((resolve) => {`,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: 'WSClient pending-socket settlement',
|
|
28
|
+
before: ` if (timer)
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
resolve(ok);`,
|
|
31
|
+
after: ` if (timer)
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
if (this.pendingWsInstance === wsInstance)
|
|
34
|
+
this.pendingWsInstance = null;
|
|
35
|
+
resolve(ok);`,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: 'WSClient handshake-timeout listener cleanup',
|
|
39
|
+
before: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
|
|
40
|
+
wsInstance.removeAllListeners();`,
|
|
41
|
+
after: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
|
|
42
|
+
wsInstance.removeAllListeners('open');`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'WSClient reconnect generation fences',
|
|
46
|
+
before: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
|
|
47
|
+
this.reconnectInfo.lastConnectTime = Date.now();
|
|
48
|
+
const pullResult = yield this.pullConnectConfig();
|
|
49
|
+
if (!pullResult.ok)
|
|
50
|
+
return pullResult;
|
|
51
|
+
const connected = yield this.connect();
|
|
52
|
+
if (!connected)
|
|
53
|
+
return { ok: false, retryable: true };
|
|
54
|
+
this.communicate();
|
|
55
|
+
return { ok: true };
|
|
56
|
+
});`,
|
|
57
|
+
after: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
|
|
58
|
+
if (currentGeneration !== this.reconnectGeneration)
|
|
59
|
+
return { ok: false, retryable: false, cancelled: true };
|
|
60
|
+
this.reconnectInfo.lastConnectTime = Date.now();
|
|
61
|
+
const pullResult = yield this.pullConnectConfig();
|
|
62
|
+
if (currentGeneration !== this.reconnectGeneration)
|
|
63
|
+
return { ok: false, retryable: false, cancelled: true };
|
|
64
|
+
if (!pullResult.ok)
|
|
65
|
+
return pullResult;
|
|
66
|
+
const connected = yield this.connect();
|
|
67
|
+
if (currentGeneration !== this.reconnectGeneration)
|
|
68
|
+
return { ok: false, retryable: false, cancelled: true };
|
|
69
|
+
if (!connected)
|
|
70
|
+
return { ok: false, retryable: true };
|
|
71
|
+
this.communicate();
|
|
72
|
+
return { ok: true };
|
|
73
|
+
});`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: 'WSClient initial-connect cancellation fence',
|
|
77
|
+
before: ` try {
|
|
78
|
+
result = yield tryConnect();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
this.isConnecting = false;
|
|
82
|
+
}
|
|
83
|
+
if (result.ok) {`,
|
|
84
|
+
after: ` try {
|
|
85
|
+
result = yield tryConnect();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
if (currentGeneration === this.reconnectGeneration) {
|
|
89
|
+
this.isConnecting = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (currentGeneration !== this.reconnectGeneration || result.cancelled) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (result.ok) {`,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: 'WSClient pending-socket close',
|
|
99
|
+
before: ` const wsInstance = this.wsConfig.getWSInstance();
|
|
100
|
+
if (wsInstance) {`,
|
|
101
|
+
after: ` const pendingWsInstance = this.pendingWsInstance;
|
|
102
|
+
if (pendingWsInstance) {
|
|
103
|
+
this.pendingWsInstance = null;
|
|
104
|
+
pendingWsInstance.removeAllListeners('open');
|
|
105
|
+
try {
|
|
106
|
+
if (force) {
|
|
107
|
+
pendingWsInstance.terminate();
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
pendingWsInstance.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch ( /* best effort */_a) { /* best effort */ }
|
|
114
|
+
}
|
|
115
|
+
const wsInstance = this.wsConfig.getWSInstance();
|
|
116
|
+
if (wsInstance) {`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
label: 'WSClient idempotent start guard',
|
|
120
|
+
before: ` const { eventDispatcher } = params;
|
|
121
|
+
if (!eventDispatcher) {
|
|
122
|
+
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Clear any terminal-error state left over from a previous session so`,
|
|
126
|
+
after: ` const { eventDispatcher } = params;
|
|
127
|
+
if (!eventDispatcher) {
|
|
128
|
+
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const liveWsInstance = this.wsConfig.getWSInstance();
|
|
132
|
+
if (this.terminalError) {
|
|
133
|
+
this.isConnecting = false;
|
|
134
|
+
}
|
|
135
|
+
if (this.isConnecting ||
|
|
136
|
+
(liveWsInstance && liveWsInstance.readyState !== WebSocket.CLOSED)) {
|
|
137
|
+
this.logger.debug('[ws]', 'start ignored because client is already connecting or connected');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Clear any terminal-error state left over from a previous session so`,
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function replaceExactlyOnce(source, patch, sourcePath) {
|
|
145
|
+
const matches = source.split(patch.before).length - 1;
|
|
146
|
+
if (matches !== 1) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${sourcePath}: expected exactly one reviewed ${patch.label} marker, found ${matches}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return source.replace(patch.before, patch.after);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Patch @larksuiteoapi/node-sdk 1.73.0's WSClient lifecycle.
|
|
156
|
+
*
|
|
157
|
+
* The vendor client cannot normally close a socket until its WebSocket
|
|
158
|
+
* handshake has opened. Its timeout also removes the socket's `error`
|
|
159
|
+
* listener before terminating it, and the initial connection path can resume
|
|
160
|
+
* after close() and start a zombie reconnect loop. Track and safely terminate
|
|
161
|
+
* the pending socket, retain its error listener, and fence every async initial
|
|
162
|
+
* connection stage with the reconnect generation already maintained by the
|
|
163
|
+
* SDK. Every reviewed source fragment must match exactly once so an SDK source
|
|
164
|
+
* change fails the build instead of silently losing the compatibility fix.
|
|
165
|
+
*/
|
|
166
|
+
export function patchLarkSdkHandshakeSource(source, sourcePath = 'Lark SDK') {
|
|
167
|
+
return PATCHES.reduce(
|
|
168
|
+
(patched, patch) => replaceExactlyOnce(patched, patch, sourcePath),
|
|
169
|
+
source,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const larkSdkHandshakePatch = {
|
|
174
|
+
name: 'dsh-lark-sdk-websocket-lifecycle-fix',
|
|
175
|
+
setup(build) {
|
|
176
|
+
build.onLoad({ filter: /@larksuiteoapi[\\/]node-sdk[\\/](es|lib)[\\/]index\.js$/ }, async ({ path }) => ({
|
|
177
|
+
contents: patchLarkSdkHandshakeSource(await readFile(path, 'utf8'), path),
|
|
178
|
+
loader: 'js',
|
|
179
|
+
}));
|
|
180
|
+
},
|
|
181
|
+
};
|