@xmanrui/dsh-im 0.1.0 → 0.2.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/README.md +32 -12
- package/THIRD_PARTY_NOTICES.md +35 -3
- package/lib/client.js +772 -68
- package/lib/index.js +255 -13339
- package/package.json +5 -4
- package/plugin-src/client/channel-logos.js +13 -0
- package/plugin-src/client/channels/shared/token-channel.js +32 -15
- package/plugin-src/client/channels/whatsapp/api.js +123 -0
- package/plugin-src/client/channels/whatsapp/index.js +433 -0
- package/plugin-src/client/channels/whatsapp/styles.js +19 -0
- package/plugin-src/client/index.js +20 -2
- package/plugin-src/client/styles.js +3 -1
- package/plugin-src/host/build.mjs +22 -2
- package/plugin-src/host/channels/whatsapp/index.mjs +35 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +121 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +140 -0
- package/plugin-src/host/index.mjs +3 -0
- package/scripts/verify-package.mjs +28 -2
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/whatsapp/config-store.mjs +165 -0
- package/src/channels/whatsapp/harness-client.mjs +3 -0
- package/src/channels/whatsapp/state-store.mjs +3 -0
- package/src/channels/whatsapp/whatsapp-bridge.mjs +15 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +388 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +299 -0
- package/src/channels/whatsapp/whatsapp-web-session.mjs +212 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { rm, unlink } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { WhatsappConfigStore } from '../../../../src/channels/whatsapp/config-store.mjs';
|
|
6
|
+
import { WhatsappHarnessClient } from '../../../../src/channels/whatsapp/harness-client.mjs';
|
|
7
|
+
import { WhatsappStateStore } from '../../../../src/channels/whatsapp/state-store.mjs';
|
|
8
|
+
import { WhatsappController } from '../../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
|
9
|
+
import { WhatsappRuntime } from '../../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
|
10
|
+
import { createWhatsappWebSession } from '../../../../src/channels/whatsapp/whatsapp-web-session.mjs';
|
|
11
|
+
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
|
12
|
+
|
|
13
|
+
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
|
14
|
+
|
|
15
|
+
function harnessOrigin(webServer, configured) {
|
|
16
|
+
if (configured !== undefined) return new URL(configured);
|
|
17
|
+
const port = webServer?.port;
|
|
18
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
19
|
+
throw new Error('dsh-im WhatsApp requires an initialized DSH webServer port');
|
|
20
|
+
}
|
|
21
|
+
return new URL(`http://127.0.0.1:${port}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pluginPaths(config) {
|
|
25
|
+
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
|
26
|
+
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-whatsapp'));
|
|
27
|
+
const authRoot = resolve(config.authDir ?? join(root, 'auth'));
|
|
28
|
+
const authPath = (name) => {
|
|
29
|
+
if (!AUTH_DIRECTORY_PATTERN.test(name ?? '')) throw new TypeError('Invalid WhatsApp auth directory');
|
|
30
|
+
return resolve(authRoot, name);
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
config: resolve(config.configPath ?? join(root, 'config.json')),
|
|
34
|
+
bots: resolve(config.botsDir ?? join(root, 'bots')),
|
|
35
|
+
authPath,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
40
|
+
if (!ctx?.webServer) throw new TypeError('dsh-im WhatsApp requires ctx.webServer');
|
|
41
|
+
const logger = typeof ctx.logger === 'function'
|
|
42
|
+
? ctx.logger('dsh-im:whatsapp') : (ctx.logger ?? console);
|
|
43
|
+
const ConfigStore = internals.ConfigStore ?? WhatsappConfigStore;
|
|
44
|
+
const StateStore = internals.StateStore ?? WhatsappStateStore;
|
|
45
|
+
const Harness = internals.HarnessClient ?? WhatsappHarnessClient;
|
|
46
|
+
const Controller = internals.Controller ?? WhatsappController;
|
|
47
|
+
const Runtime = internals.Runtime ?? WhatsappRuntime;
|
|
48
|
+
const createSession = internals.createSession ?? createWhatsappWebSession;
|
|
49
|
+
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
|
50
|
+
const paths = pluginPaths(config);
|
|
51
|
+
const configStore = await new ConfigStore(paths.config).load();
|
|
52
|
+
const stateStores = new Map();
|
|
53
|
+
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
|
|
54
|
+
const stateFor = async (botId) => {
|
|
55
|
+
let state = stateStores.get(botId);
|
|
56
|
+
if (!state) {
|
|
57
|
+
state = await new StateStore(statePath(botId)).load();
|
|
58
|
+
stateStores.set(botId, state);
|
|
59
|
+
}
|
|
60
|
+
return state;
|
|
61
|
+
};
|
|
62
|
+
const harness = new Harness({
|
|
63
|
+
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
64
|
+
workspace: resolve(config.workspace ?? process.cwd()),
|
|
65
|
+
agentPreset: config.agentPreset ?? 'standard',
|
|
66
|
+
autostart: false,
|
|
67
|
+
dshBin: config.dshBin ?? 'dsh',
|
|
68
|
+
});
|
|
69
|
+
const controller = new Controller({
|
|
70
|
+
configStore,
|
|
71
|
+
authPath: paths.authPath,
|
|
72
|
+
createSession,
|
|
73
|
+
logger,
|
|
74
|
+
createRuntime: async ({ botId, config: botConfig, authDir }) => new Runtime({
|
|
75
|
+
config: botConfig,
|
|
76
|
+
authDir,
|
|
77
|
+
harness,
|
|
78
|
+
state: await stateFor(botId),
|
|
79
|
+
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
|
80
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
|
|
81
|
+
createSession,
|
|
82
|
+
logger: {
|
|
83
|
+
error: (...args) => logger.error?.(`[${botId}]`, ...args),
|
|
84
|
+
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
|
|
85
|
+
info: (...args) => logger.info?.(`[${botId}]`, ...args),
|
|
86
|
+
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
deleteAuth: (authDirectory) => rm(paths.authPath(authDirectory), {
|
|
90
|
+
recursive: true,
|
|
91
|
+
force: true,
|
|
92
|
+
}),
|
|
93
|
+
deleteState: async ({ botId }) => {
|
|
94
|
+
const state = stateStores.get(botId);
|
|
95
|
+
stateStores.delete(botId);
|
|
96
|
+
if (state && typeof state.remove === 'function') return state.remove();
|
|
97
|
+
try {
|
|
98
|
+
await unlink(statePath(botId));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const supervisor = createSupervisor({
|
|
105
|
+
channel: 'whatsapp',
|
|
106
|
+
controller,
|
|
107
|
+
harness,
|
|
108
|
+
logger,
|
|
109
|
+
retryDelaysMs: config.retryDelaysMs,
|
|
110
|
+
healthyIntervalMs: config.healthyIntervalMs,
|
|
111
|
+
}).start();
|
|
112
|
+
return {
|
|
113
|
+
controller,
|
|
114
|
+
ready: supervisor.ready,
|
|
115
|
+
async close() {
|
|
116
|
+
await supervisor.close();
|
|
117
|
+
await controller.close();
|
|
118
|
+
harness.stopManagedProcess();
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
2
|
+
|
|
3
|
+
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
|
4
|
+
|
|
5
|
+
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
|
|
6
|
+
export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|
7
|
+
status: 'connection.status',
|
|
8
|
+
beginProvisioning: 'provision.begin',
|
|
9
|
+
pollProvisioning: 'provision.poll',
|
|
10
|
+
cancelProvisioning: 'provision.cancel',
|
|
11
|
+
reconnectBot: 'bot.reconnect',
|
|
12
|
+
deleteBot: 'bot.delete',
|
|
13
|
+
});
|
|
14
|
+
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
|
|
15
|
+
|
|
16
|
+
const FORBIDDEN_PUBLIC_KEYS = new Set(['qrValue', 'accountJid', 'authDirectory']);
|
|
17
|
+
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function exactKeys(value, allowed) {
|
|
23
|
+
return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validId(value) {
|
|
27
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function payloadFailure(endpoint, payload) {
|
|
31
|
+
if (!isRecord(payload)) return 'Payload must be an object.';
|
|
32
|
+
if ([WHATSAPP_ENDPOINTS.status, WHATSAPP_ENDPOINTS.beginProvisioning].includes(endpoint)) {
|
|
33
|
+
return exactKeys(payload, []) ? null : `${endpoint} does not accept fields.`;
|
|
34
|
+
}
|
|
35
|
+
if ([WHATSAPP_ENDPOINTS.pollProvisioning, WHATSAPP_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
|
|
36
|
+
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
|
|
37
|
+
? null : `${endpoint} requires an attemptId.`;
|
|
38
|
+
}
|
|
39
|
+
if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
|
|
40
|
+
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
|
41
|
+
? null : 'bot.reconnect requires a botId.';
|
|
42
|
+
}
|
|
43
|
+
if (endpoint === WHATSAPP_ENDPOINTS.deleteBot) {
|
|
44
|
+
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
|
|
45
|
+
&& payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
|
|
46
|
+
}
|
|
47
|
+
return 'Unknown WhatsApp endpoint.';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizePublic(value) {
|
|
51
|
+
if (Array.isArray(value)) return value.map(sanitizePublic);
|
|
52
|
+
if (!isRecord(value)) return value;
|
|
53
|
+
const safe = {};
|
|
54
|
+
for (const [key, child] of Object.entries(value)) {
|
|
55
|
+
if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
|
|
56
|
+
}
|
|
57
|
+
return safe;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function qrDataUrl(value) {
|
|
61
|
+
return QRCode.toDataURL(value, {
|
|
62
|
+
type: 'image/png',
|
|
63
|
+
errorCorrectionLevel: 'M',
|
|
64
|
+
margin: 2,
|
|
65
|
+
width: 320,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function encodeAttempt(value, encodeQr) {
|
|
70
|
+
if (!value || typeof value.qrValue !== 'string') return sanitizePublic(value);
|
|
71
|
+
return sanitizePublic({ ...value, qrCodeDataUrl: await encodeQr(value.qrValue) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function publicStatus(value, encodeQr) {
|
|
75
|
+
const snapshot = structuredClone(value);
|
|
76
|
+
if (snapshot?.provisioning) snapshot.provisioning = await encodeAttempt(snapshot.provisioning, encodeQr);
|
|
77
|
+
return sanitizePublic(snapshot);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|
81
|
+
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
|
|
82
|
+
if (typeof controller?.[method] !== 'function') {
|
|
83
|
+
throw new TypeError(`A complete WhatsApp controller is required (${method})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const qrCache = new Map();
|
|
87
|
+
const cachedEncode = (value) => {
|
|
88
|
+
let encoded = qrCache.get(value);
|
|
89
|
+
if (!encoded) {
|
|
90
|
+
if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value);
|
|
91
|
+
encoded = Promise.resolve().then(() => encodeQr(value));
|
|
92
|
+
qrCache.set(value, encoded);
|
|
93
|
+
}
|
|
94
|
+
return encoded;
|
|
95
|
+
};
|
|
96
|
+
return async (endpoint, payload, signal) => {
|
|
97
|
+
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
|
98
|
+
if (!WHATSAPP_RPC_ENDPOINTS.includes(endpoint)) {
|
|
99
|
+
return { ok: false, error: { code: 'bad-request', message: 'Unknown WhatsApp endpoint.' } };
|
|
100
|
+
}
|
|
101
|
+
const invalid = payloadFailure(endpoint, payload);
|
|
102
|
+
if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
|
|
103
|
+
try {
|
|
104
|
+
let value;
|
|
105
|
+
if (endpoint === WHATSAPP_ENDPOINTS.status) {
|
|
106
|
+
value = await publicStatus(await controller.status(), cachedEncode);
|
|
107
|
+
} else if (endpoint === WHATSAPP_ENDPOINTS.beginProvisioning) {
|
|
108
|
+
value = await encodeAttempt(await controller.startProvisioning(), cachedEncode);
|
|
109
|
+
} else if (endpoint === WHATSAPP_ENDPOINTS.pollProvisioning) {
|
|
110
|
+
const attempt = await controller.registrationStatus(payload.attemptId);
|
|
111
|
+
if (!attempt) return { ok: false, error: { code: 'bad-request', message: 'The provisioning attempt no longer exists.' } };
|
|
112
|
+
value = await encodeAttempt(attempt, cachedEncode);
|
|
113
|
+
} else if (endpoint === WHATSAPP_ENDPOINTS.cancelProvisioning) {
|
|
114
|
+
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
|
|
115
|
+
} else if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
|
|
116
|
+
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
|
117
|
+
} else {
|
|
118
|
+
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
|
|
119
|
+
}
|
|
120
|
+
return signal?.aborted
|
|
121
|
+
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
|
122
|
+
: { ok: true, value };
|
|
123
|
+
} catch {
|
|
124
|
+
return signal?.aborted
|
|
125
|
+
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
|
126
|
+
: { ok: false, error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' } };
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function installWhatsappRpc(ctx, controller, options, authority) {
|
|
132
|
+
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
|
133
|
+
throw new TypeError('DSH Host Connection RPC is required');
|
|
134
|
+
}
|
|
135
|
+
return ctx.connection.rpc.handle(
|
|
136
|
+
WHATSAPP_RPC_CHANNEL,
|
|
137
|
+
createWhatsappRpcHandler(controller, options),
|
|
138
|
+
{ authority: resolveRpcAuthority(authority) },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -5,6 +5,7 @@ import { apply as applyQq } from './channels/qq/index.mjs';
|
|
|
5
5
|
import { apply as applyTelegram } from './channels/telegram/index.mjs';
|
|
6
6
|
import { apply as applyWecom } from './channels/wecom/index.mjs';
|
|
7
7
|
import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
|
8
|
+
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
|
|
8
9
|
|
|
9
10
|
export const name = 'dsh-im-host';
|
|
10
11
|
export const inject = ['connection', 'credentials', 'webServer'];
|
|
@@ -24,6 +25,7 @@ export function createImHostPlugin(internals = {}) {
|
|
|
24
25
|
const startQq = internals.applyQq ?? applyQq;
|
|
25
26
|
const startTelegram = internals.applyTelegram ?? applyTelegram;
|
|
26
27
|
const startDiscord = internals.applyDiscord ?? applyDiscord;
|
|
28
|
+
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
|
|
27
29
|
return Object.freeze({
|
|
28
30
|
name,
|
|
29
31
|
inject,
|
|
@@ -35,6 +37,7 @@ export function createImHostPlugin(internals = {}) {
|
|
|
35
37
|
await startQq(ctx, channelConfig(config, 'qq'));
|
|
36
38
|
await startTelegram(ctx, channelConfig(config, 'telegram'));
|
|
37
39
|
await startDiscord(ctx, channelConfig(config, 'discord'));
|
|
40
|
+
await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
|
|
38
41
|
},
|
|
39
42
|
});
|
|
40
43
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
3
4
|
|
|
4
5
|
const root = resolve(import.meta.dirname, '..');
|
|
5
6
|
const required = [
|
|
@@ -17,6 +18,7 @@ const required = [
|
|
|
17
18
|
'plugin-src/host/channels/wecom/index.mjs',
|
|
18
19
|
'plugin-src/host/channels/telegram/index.mjs',
|
|
19
20
|
'plugin-src/host/channels/discord/index.mjs',
|
|
21
|
+
'plugin-src/host/channels/whatsapp/index.mjs',
|
|
20
22
|
'src/channels/feishu/feishu-runtime.mjs',
|
|
21
23
|
'src/channels/weixin/weixin-runtime.mjs',
|
|
22
24
|
'src/channels/dingtalk/dingtalk-runtime.mjs',
|
|
@@ -24,6 +26,8 @@ const required = [
|
|
|
24
26
|
'src/channels/wecom/wecom-runtime.mjs',
|
|
25
27
|
'src/channels/telegram/telegram-runtime.mjs',
|
|
26
28
|
'src/channels/discord/discord-runtime.mjs',
|
|
29
|
+
'src/channels/whatsapp/whatsapp-runtime.mjs',
|
|
30
|
+
'src/channels/whatsapp/whatsapp-web-session.mjs',
|
|
27
31
|
];
|
|
28
32
|
await Promise.all(required.map((path) => access(resolve(root, path))));
|
|
29
33
|
|
|
@@ -38,6 +42,7 @@ const [client, host, patch, manifestText, lockText, hostSource, clientSource, ex
|
|
|
38
42
|
stat(resolve(root, 'bin/dsh-im.mjs')),
|
|
39
43
|
]);
|
|
40
44
|
const manifest = JSON.parse(manifestText);
|
|
45
|
+
const lock = JSON.parse(lockText);
|
|
41
46
|
|
|
42
47
|
if (!client.includes('id: "@xmanrui/dsh-im"')) {
|
|
43
48
|
throw new Error('client bundle does not register the dsh-im loader id');
|
|
@@ -55,7 +60,7 @@ if (!client.includes('container-type: inline-size')
|
|
|
55
60
|
|| !client.includes('@container (max-width: 680px)')) {
|
|
56
61
|
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
|
|
57
62
|
}
|
|
58
|
-
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/telegram', '/discord']) {
|
|
63
|
+
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/telegram', '/discord', '/whatsapp']) {
|
|
59
64
|
if (!host.includes(marker)) {
|
|
60
65
|
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
|
61
66
|
}
|
|
@@ -77,7 +82,6 @@ for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin', '@xmanrui/dsh-
|
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
const directDependencies = {
|
|
80
|
-
'@larksuiteoapi/node-sdk': '1.73.0',
|
|
81
85
|
'dingtalk-stream': '2.1.4',
|
|
82
86
|
'@tencent-connect/qqbot-connector': '1.2.0',
|
|
83
87
|
'@tencent-connect/qqbot-nodejs': '1.0.4',
|
|
@@ -89,9 +93,31 @@ for (const [name, version] of Object.entries(directDependencies)) {
|
|
|
89
93
|
throw new Error(`${name} must be a pinned direct dependency at ${version}`);
|
|
90
94
|
}
|
|
91
95
|
}
|
|
96
|
+
const bundledBuildDependencies = {
|
|
97
|
+
'@larksuiteoapi/node-sdk': '1.73.0',
|
|
98
|
+
'@whiskeysockets/baileys': '7.0.0-rc14',
|
|
99
|
+
};
|
|
100
|
+
for (const [name, version] of Object.entries(bundledBuildDependencies)) {
|
|
101
|
+
if (manifest.dependencies?.[name] !== undefined) {
|
|
102
|
+
throw new Error(`${name} must not remain a runtime dependency`);
|
|
103
|
+
}
|
|
104
|
+
if (manifest.devDependencies?.[name] !== version) {
|
|
105
|
+
throw new Error(`${name} must be a pinned build dependency at ${version}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (lock.packages?.['node_modules/protobufjs']?.dev !== true) {
|
|
109
|
+
throw new Error('protobufjs must remain build-only in the package lock');
|
|
110
|
+
}
|
|
111
|
+
if (manifest.bin?.['dsh-im'] !== 'bin/dsh-im.mjs') {
|
|
112
|
+
throw new Error('package manifest must publish the dsh-im executable');
|
|
113
|
+
}
|
|
114
|
+
if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whiskeysockets\/baileys|protobufjs)(?:\/[^"']*)?["']/.test(host)) {
|
|
115
|
+
throw new Error('host bundle must not import a bundled SDK or protobufjs at runtime');
|
|
116
|
+
}
|
|
92
117
|
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
|
|
93
118
|
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
|
|
94
119
|
throw new Error('built artifacts contain a test or environment secret marker');
|
|
95
120
|
}
|
|
121
|
+
await import(pathToFileURL(resolve(root, 'lib/index.js')).href);
|
|
96
122
|
|
|
97
123
|
console.log('Verified dsh-im package artifacts.');
|
|
@@ -108,7 +108,7 @@ export class DiscordApi {
|
|
|
108
108
|
headers: {
|
|
109
109
|
authorization: `Bot ${this.#token}`,
|
|
110
110
|
'content-type': 'application/json',
|
|
111
|
-
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.
|
|
111
|
+
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.2.0)',
|
|
112
112
|
},
|
|
113
113
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
114
114
|
signal: requestSignal(signal, timeoutMs),
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const EMPTY_DOCUMENT = Object.freeze({ version: 2, bots: Object.freeze([]) });
|
|
6
|
+
const BOT_ID_PATTERN = /^whatsapp_[a-f0-9]{24}$/;
|
|
7
|
+
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
|
8
|
+
|
|
9
|
+
function cleanString(value) {
|
|
10
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeWhatsappAccountJid(value) {
|
|
14
|
+
const jid = cleanString(value)?.toLowerCase();
|
|
15
|
+
return /^\d{5,32}@(s\.whatsapp\.net|lid)$/.test(jid ?? '') ? jid : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function deriveWhatsappBotId(accountJid) {
|
|
19
|
+
const normalized = normalizeWhatsappAccountJid(accountJid);
|
|
20
|
+
if (!normalized) throw new TypeError('A valid WhatsApp account JID is required');
|
|
21
|
+
return `whatsapp_${createHash('sha256').update(normalized).digest('hex').slice(0, 24)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function maskWhatsappAccount(accountJid) {
|
|
25
|
+
const digits = normalizeWhatsappAccountJid(accountJid)?.split('@')[0] ?? '';
|
|
26
|
+
if (!digits) return 'WhatsApp账号';
|
|
27
|
+
if (digits.length <= 7) return `${digits.slice(0, 2)}•••${digits.slice(-2)}`;
|
|
28
|
+
return `${digits.slice(0, 4)}••••${digits.slice(-4)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class WhatsappConfigStore {
|
|
32
|
+
#path;
|
|
33
|
+
#value = EMPTY_DOCUMENT;
|
|
34
|
+
#writeQueue = Promise.resolve();
|
|
35
|
+
|
|
36
|
+
constructor(path) {
|
|
37
|
+
this.#path = path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async load() {
|
|
41
|
+
try {
|
|
42
|
+
const normalized = this.#normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
43
|
+
if (!normalized) throw new Error('dsh-im WhatsApp config contains invalid account data');
|
|
44
|
+
this.#value = normalized;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
47
|
+
this.#value = EMPTY_DOCUMENT;
|
|
48
|
+
}
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
list() {
|
|
53
|
+
return structuredClone(this.#value.bots);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get(botId) {
|
|
57
|
+
const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
|
|
58
|
+
return bot ? structuredClone(bot) : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getByAccountJid(accountJid) {
|
|
62
|
+
const normalized = normalizeWhatsappAccountJid(accountJid);
|
|
63
|
+
const bot = this.#value.bots.find((candidate) => candidate.accountJid === normalized);
|
|
64
|
+
return bot ? structuredClone(bot) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async save(value) {
|
|
68
|
+
const normalized = this.#normalizeBot(value);
|
|
69
|
+
if (!normalized) throw new Error('Refusing to persist incomplete WhatsApp account data');
|
|
70
|
+
return this.#mutate((bots) => {
|
|
71
|
+
const duplicate = bots.find((bot) => bot.accountJid === normalized.accountJid
|
|
72
|
+
&& bot.botId !== normalized.botId);
|
|
73
|
+
const authCollision = bots.find((bot) => bot.authDirectory === normalized.authDirectory
|
|
74
|
+
&& bot.botId !== normalized.botId);
|
|
75
|
+
if (duplicate || authCollision) throw new Error('Duplicate WhatsApp account identity');
|
|
76
|
+
const index = bots.findIndex((bot) => bot.botId === normalized.botId);
|
|
77
|
+
if (index === -1) bots.push(normalized);
|
|
78
|
+
else bots[index] = normalized;
|
|
79
|
+
return structuredClone(normalized);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async remove(botId) {
|
|
84
|
+
if (!BOT_ID_PATTERN.test(botId)) throw new TypeError('Invalid WhatsApp bot id');
|
|
85
|
+
return this.#mutate((bots) => {
|
|
86
|
+
const index = bots.findIndex((bot) => bot.botId === botId);
|
|
87
|
+
if (index === -1) return null;
|
|
88
|
+
const [removed] = bots.splice(index, 1);
|
|
89
|
+
return structuredClone(removed);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async clear() {
|
|
94
|
+
const operation = this.#writeQueue.then(async () => {
|
|
95
|
+
try {
|
|
96
|
+
await unlink(this.#path);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
99
|
+
}
|
|
100
|
+
this.#value = EMPTY_DOCUMENT;
|
|
101
|
+
});
|
|
102
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
103
|
+
await operation;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#normalizeBot(value) {
|
|
107
|
+
if (!value || typeof value !== 'object') return null;
|
|
108
|
+
const accountJid = normalizeWhatsappAccountJid(value.accountJid);
|
|
109
|
+
const botId = cleanString(value.botId);
|
|
110
|
+
const authDirectory = cleanString(value.authDirectory);
|
|
111
|
+
const name = cleanString(value.name);
|
|
112
|
+
if (!accountJid || !botId || !authDirectory || !name
|
|
113
|
+
|| !BOT_ID_PATTERN.test(botId) || !AUTH_DIRECTORY_PATTERN.test(authDirectory)
|
|
114
|
+
|| deriveWhatsappBotId(accountJid) !== botId) return null;
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
botId,
|
|
117
|
+
accountJid,
|
|
118
|
+
authDirectory,
|
|
119
|
+
name,
|
|
120
|
+
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
|
121
|
+
connectedAt: cleanString(value.connectedAt),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#normalizeDocument(value) {
|
|
126
|
+
if (!value || value.version !== 2 || !Array.isArray(value.bots)) return null;
|
|
127
|
+
const bots = value.bots.map((bot) => this.#normalizeBot(bot));
|
|
128
|
+
if (bots.some((bot) => bot === null)) return null;
|
|
129
|
+
const botIds = new Set();
|
|
130
|
+
const accountJids = new Set();
|
|
131
|
+
const authDirectories = new Set();
|
|
132
|
+
for (const bot of bots) {
|
|
133
|
+
if (botIds.has(bot.botId) || accountJids.has(bot.accountJid)
|
|
134
|
+
|| authDirectories.has(bot.authDirectory)) return null;
|
|
135
|
+
botIds.add(bot.botId);
|
|
136
|
+
accountJids.add(bot.accountJid);
|
|
137
|
+
authDirectories.add(bot.authDirectory);
|
|
138
|
+
}
|
|
139
|
+
return Object.freeze({ version: 2, bots: Object.freeze(bots) });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #mutate(mutator) {
|
|
143
|
+
let result;
|
|
144
|
+
const operation = this.#writeQueue.then(async () => {
|
|
145
|
+
const bots = [...this.#value.bots];
|
|
146
|
+
result = mutator(bots);
|
|
147
|
+
const document = Object.freeze({ version: 2, bots: Object.freeze(bots) });
|
|
148
|
+
await this.#write(document);
|
|
149
|
+
this.#value = document;
|
|
150
|
+
});
|
|
151
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
152
|
+
await operation;
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #write(document) {
|
|
157
|
+
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
158
|
+
const temporary = `${this.#path}.tmp`;
|
|
159
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
|
160
|
+
encoding: 'utf8',
|
|
161
|
+
mode: 0o600,
|
|
162
|
+
});
|
|
163
|
+
await rename(temporary, this.#path);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createTextBridgeStatus, TextHarnessBridge } from '../shared/text-harness-bridge.mjs';
|
|
2
|
+
|
|
3
|
+
export const WHATSAPP_DESCRIPTOR = Object.freeze({
|
|
4
|
+
key: 'whatsapp',
|
|
5
|
+
label: 'WhatsApp',
|
|
6
|
+
connectionLabel: ' Web 关联设备',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export class WhatsappHarnessBridge extends TextHarnessBridge {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
super({ ...options, descriptor: WHATSAPP_DESCRIPTOR });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { createTextBridgeStatus as createWhatsappBridgeStatus };
|