dsh-lan-peers 0.8.1 → 0.8.2
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/client/cli-dispatch.test.mjs +226 -0
- package/client/cli.mjs +117 -2
- package/host/server.mjs +372 -7
- package/host/test/group-cluster.mjs +3 -0
- package/host/test/group-dispatch.test.mjs +488 -0
- package/lib/admin-dispatch.test.mjs +358 -0
- package/lib/admin-group.test.mjs +5 -2
- package/lib/admin.mjs +336 -4
- package/lib/group-client.mjs +568 -18
- package/lib/index.js +15 -10
- package/lib/panel-dispatch.test.mjs +345 -0
- package/lib/panel-group.test.mjs +4 -1
- package/lib/tools.mjs +77 -3
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// dsh-lan-peers · CLI 的群内派单可见面(v0.8.2-α,契约 §1/§4/§5)。
|
|
2
|
+
//
|
|
3
|
+
// 真进程跑 cli.mjs:hub 在本进程里起(所以子进程必须异步跑),本机 host 的管理接口
|
|
4
|
+
// 用一个小 HTTP 桩代替 —— 派单执行在 host 里(Lane X),CLI 只负责「发完消息再去触发」。
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { createPeerServer } from '../host/server.mjs';
|
|
13
|
+
import { makeTempStateDir, peerFixture, seedPeers, writeJson0600 } from '../host/test/helpers.mjs';
|
|
14
|
+
|
|
15
|
+
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
|
|
16
|
+
const MEMBER = peerFixture('peer-cli-dispatch');
|
|
17
|
+
|
|
18
|
+
function runAsync(args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile(process.execPath, [CLI, ...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
21
|
+
resolve({ status: error ? error.code : 0, stdout, stderr });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function startHub(t, overrides = {}) {
|
|
27
|
+
const stateDir = makeTempStateDir();
|
|
28
|
+
seedPeers(stateDir, overrides.peers ?? [MEMBER]);
|
|
29
|
+
const server = createPeerServer({
|
|
30
|
+
listenHost: '127.0.0.1',
|
|
31
|
+
stateDir,
|
|
32
|
+
// 这个文件测的是 CLI 行为:quorum:none = 写本地即提交,不牵扯选举投票。
|
|
33
|
+
group: { enabled: true, autoJoin: true, quorum: 'none' },
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
const info = await server.listen(0, '127.0.0.1');
|
|
37
|
+
t.after(async () => {
|
|
38
|
+
await server.close();
|
|
39
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
return { server, baseUrl: info.url, stateDir };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function memberStateDir(t, { baseUrl, hubInstanceId, identity = MEMBER, peerName = 'hub' }) {
|
|
45
|
+
const dir = makeTempStateDir('dsh-lan-peers-cli-dispatch-');
|
|
46
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
47
|
+
writeJson0600(path.join(dir, 'state.json'), {
|
|
48
|
+
instanceId: identity.peerId,
|
|
49
|
+
name: identity.name,
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
writeJson0600(path.join(dir, 'peers.json'), {
|
|
53
|
+
peers: {
|
|
54
|
+
[hubInstanceId]: {
|
|
55
|
+
name: peerName,
|
|
56
|
+
baseUrl,
|
|
57
|
+
secret: identity.inboundSecret,
|
|
58
|
+
inboundSecret: 'unused-outbound',
|
|
59
|
+
addedAt: new Date().toISOString(),
|
|
60
|
+
lastSeen: null,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 本机 host 的管理接口桩:只实现 group/dispatch 与 group/accept,记录每一次请求。 */
|
|
68
|
+
async function startAdminStub(t, handler) {
|
|
69
|
+
const requests = [];
|
|
70
|
+
const server = http.createServer((req, res) => {
|
|
71
|
+
let raw = '';
|
|
72
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
73
|
+
req.on('end', () => {
|
|
74
|
+
const payload = raw === '' ? null : JSON.parse(raw);
|
|
75
|
+
requests.push({ method: req.method, url: String(req.url), payload });
|
|
76
|
+
let answer = {};
|
|
77
|
+
try {
|
|
78
|
+
answer = handler({ url: String(req.url), payload }) || {};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
answer = { status: 500, json: { error: String(error.message || error) } };
|
|
81
|
+
}
|
|
82
|
+
res.writeHead(Number(answer.status) || 200, { 'Content-Type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify(answer.json ?? {}));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
87
|
+
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
88
|
+
return { requests, url: 'http://127.0.0.1:' + server.address().port };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 一条到 hub 的、已入群的成员 state-dir */
|
|
92
|
+
async function joinedMember(t) {
|
|
93
|
+
const hub = await startHub(t);
|
|
94
|
+
const dir = memberStateDir(t, { baseUrl: hub.baseUrl, hubInstanceId: hub.server.store.instanceId });
|
|
95
|
+
const joined = await runAsync(['group', 'join', '--peer', 'hub', '--state-dir', dir]);
|
|
96
|
+
assert.equal(joined.status, 0, joined.stderr);
|
|
97
|
+
return { hub, dir };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DONE = {
|
|
101
|
+
ok: true,
|
|
102
|
+
dispatch: { target: 'nas-box', targetName: 'nas-box', status: 'done', reason: '', elapsedMs: 1234, replyChars: 56 },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
test('group --help 列出 dispatch 与 accept 两个新子命令', async () => {
|
|
106
|
+
const out = await runAsync(['group', '--help']);
|
|
107
|
+
assert.match(out.stdout, /dispatch <目标> <任务\.\.\.>/);
|
|
108
|
+
assert.match(out.stdout, /accept on\|off/);
|
|
109
|
+
assert.match(out.stdout, /@!名字 时作者侧自动派单/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('group dispatch <目标> <任务>:等价发一条 @!目标 任务,再触发本机派单并打印摘要', async (t) => {
|
|
113
|
+
const { hub, dir } = await joinedMember(t);
|
|
114
|
+
const admin = await startAdminStub(t, ({ url }) => (url.includes('/group/dispatch') ? { json: DONE } : { json: { ok: true } }));
|
|
115
|
+
|
|
116
|
+
const out = await runAsync([
|
|
117
|
+
'group', 'dispatch', 'nas-box', '跑', '回归', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
118
|
+
]);
|
|
119
|
+
assert.equal(out.status, 0, out.stderr);
|
|
120
|
+
assert.match(out.stdout, /已发送 seq=\d+/);
|
|
121
|
+
assert.match(out.stdout, /派单 → nas-box:done(1234ms \/ 56字)/);
|
|
122
|
+
|
|
123
|
+
const dispatched = admin.requests.filter((request) => request.url === '/api/lan-peers/group/dispatch');
|
|
124
|
+
assert.equal(dispatched.length, 1);
|
|
125
|
+
assert.deepEqual(dispatched[0].payload, { target: 'nas-box', task: '跑 回归', extraTargets: 0 });
|
|
126
|
+
|
|
127
|
+
// 群消息先入库,且内容就是 "@!目标 任务"
|
|
128
|
+
const history = await runAsync(['group', 'history', '--peer', 'hub', '--state-dir', dir]);
|
|
129
|
+
assert.equal(history.status, 0, history.stderr);
|
|
130
|
+
assert.match(history.stdout, /@!nas-box 跑 回归/);
|
|
131
|
+
assert.ok(hub.server, 'hub 必须还活着');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('group post --text "@!X 任务":自动派单;普通消息一次都不打派单接口', async (t) => {
|
|
135
|
+
const { dir } = await joinedMember(t);
|
|
136
|
+
const admin = await startAdminStub(t, ({ url }) => (url.includes('/group/dispatch') ? { json: DONE } : { json: { ok: true } }));
|
|
137
|
+
|
|
138
|
+
const withBang = await runAsync([
|
|
139
|
+
'group', 'post', '--text', '@!nas-box 修 CI', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
140
|
+
]);
|
|
141
|
+
assert.equal(withBang.status, 0, withBang.stderr);
|
|
142
|
+
assert.match(withBang.stdout, /派单 → nas-box:done/);
|
|
143
|
+
assert.equal(admin.requests.length, 1);
|
|
144
|
+
assert.deepEqual(admin.requests[0].payload, { target: 'nas-box', task: '修 CI', extraTargets: 0 });
|
|
145
|
+
|
|
146
|
+
const plain = await runAsync([
|
|
147
|
+
'group', 'post', '--text', '@nas-box 只是提及', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
148
|
+
]);
|
|
149
|
+
assert.equal(plain.status, 0, plain.stderr);
|
|
150
|
+
assert.equal(admin.requests.length, 1, '没有 @! 就一次派单请求都不发');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('本机没有 host:消息照发,但派单未触发要如实报错 exit 2(不静默当普通消息)', async (t) => {
|
|
154
|
+
const { dir } = await joinedMember(t);
|
|
155
|
+
// 默认 --url 指向 127.0.0.1:7788:本测试里那里没有 host
|
|
156
|
+
const out = await runAsync([
|
|
157
|
+
'group', 'post', '--text', '@!nas-box 跑回归', '--peer', 'hub', '--state-dir', dir,
|
|
158
|
+
'--url', 'http://127.0.0.1:1',
|
|
159
|
+
]);
|
|
160
|
+
assert.equal(out.status, 2);
|
|
161
|
+
assert.match(out.stdout, /已发送 seq=\d+/, '群消息确实发出去了');
|
|
162
|
+
assert.match(out.stderr, /派单未触发/);
|
|
163
|
+
assert.match(out.stderr, /本机 host 未响应/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('skipped(没有 @! 目标)显示未派单,不算完成也不算失败', async (t) => {
|
|
167
|
+
const { dir } = await joinedMember(t);
|
|
168
|
+
const admin = await startAdminStub(t, () => ({ json: { ok: false, dispatch: { status: 'skipped', reason: 'no-target' } } }));
|
|
169
|
+
const out = await runAsync([
|
|
170
|
+
'group', 'dispatch', 'nas-box', '任务', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
171
|
+
]);
|
|
172
|
+
assert.equal(out.status, 0, '零动作不是失败');
|
|
173
|
+
assert.match(out.stdout, /未派单(消息里没有 @! 目标): no-target/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('派单失败(failed)打印 reason 并 exit 1', async (t) => {
|
|
177
|
+
const { dir } = await joinedMember(t);
|
|
178
|
+
const admin = await startAdminStub(t, () => ({
|
|
179
|
+
json: { ok: false, dispatch: { target: 'nas-box', status: 'failed', reason: 'not-accepting', elapsedMs: 12 } },
|
|
180
|
+
}));
|
|
181
|
+
const out = await runAsync([
|
|
182
|
+
'group', 'dispatch', 'nas-box', '任务', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
183
|
+
]);
|
|
184
|
+
assert.equal(out.status, 1);
|
|
185
|
+
assert.match(out.stdout, /派单 → nas-box:failed(not-accepting \/ 12ms)/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('group accept off:打本机管理接口并打印新状态', async (t) => {
|
|
189
|
+
const { dir } = await joinedMember(t);
|
|
190
|
+
const admin = await startAdminStub(t, ({ payload }) => ({
|
|
191
|
+
json: { ok: true, acceptDispatch: payload.acceptDispatch, persisted: true },
|
|
192
|
+
}));
|
|
193
|
+
const off = await runAsync(['group', 'accept', 'off', '--state-dir', dir, '--url', admin.url]);
|
|
194
|
+
assert.equal(off.status, 0, off.stderr);
|
|
195
|
+
assert.match(off.stdout, /本机接单:已关闭/);
|
|
196
|
+
assert.deepEqual(admin.requests[0].payload, { acceptDispatch: false });
|
|
197
|
+
|
|
198
|
+
const on = await runAsync(['group', 'accept', 'on', '--state-dir', dir, '--url', admin.url]);
|
|
199
|
+
assert.equal(on.status, 0, on.stderr);
|
|
200
|
+
assert.match(on.stdout, /本机接单:接单中/);
|
|
201
|
+
assert.deepEqual(admin.requests[1].payload, { acceptDispatch: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('group accept:host 未接线(501)→ exit 2 且说清原因', async (t) => {
|
|
205
|
+
const { dir } = await joinedMember(t);
|
|
206
|
+
const admin = await startAdminStub(t, () => ({ status: 501, json: { error: 'accept-toggle-unsupported' } }));
|
|
207
|
+
const out = await runAsync(['group', 'accept', 'off', '--state-dir', dir, '--url', admin.url]);
|
|
208
|
+
assert.equal(out.status, 2);
|
|
209
|
+
assert.match(out.stderr, /切换接单失败/);
|
|
210
|
+
assert.match(out.stderr, /accept-toggle-unsupported/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('group accept 参数必须是 on|off;persisted=false 要如实说「重启后回原值」', async (t) => {
|
|
214
|
+
const { dir } = await joinedMember(t);
|
|
215
|
+
const admin = await startAdminStub(t, ({ payload }) => ({
|
|
216
|
+
json: { ok: true, acceptDispatch: payload.acceptDispatch, persisted: false },
|
|
217
|
+
}));
|
|
218
|
+
const bad = await runAsync(['group', 'accept', 'toggle', '--state-dir', dir, '--url', admin.url]);
|
|
219
|
+
assert.equal(bad.status, 1);
|
|
220
|
+
assert.match(bad.stderr, /group accept on\|off/);
|
|
221
|
+
assert.equal(admin.requests.length, 0, '参数不对时一个请求都不发');
|
|
222
|
+
|
|
223
|
+
const ok = await runAsync(['group', 'accept', 'off', '--state-dir', dir, '--url', admin.url]);
|
|
224
|
+
assert.equal(ok.status, 0, ok.stderr);
|
|
225
|
+
assert.match(ok.stdout, /重启后回到配置值/);
|
|
226
|
+
});
|
package/client/cli.mjs
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
probePeer,
|
|
24
24
|
} from './peer-call.mjs';
|
|
25
25
|
import { readPeerEvents } from './sse.mjs';
|
|
26
|
+
// v0.8.2-α(契约 §1/§5):@! 的解析口径与面板共用一份 —— 解析属于协议,不属于某一端。
|
|
27
|
+
// 面板在管理接口里解析,CLI 在发完消息后解析,两边落在同一个函数上。
|
|
28
|
+
import { parseGroupDispatchCommand } from '../lib/admin.mjs';
|
|
26
29
|
import {
|
|
27
30
|
GROUP_APPLY_POLL_INTERVAL_MS,
|
|
28
31
|
GROUP_APPLY_POLL_TIMEOUT_MS,
|
|
@@ -137,6 +140,9 @@ const GROUP_USAGE = [
|
|
|
137
140
|
'子命令:',
|
|
138
141
|
' state 群信息 + 成员表(会拿到 groupKey,但不打印内容)',
|
|
139
142
|
' post --text "..." 发一条消息 [--kind chat|progress|result|system]',
|
|
143
|
+
' 正文含 @!名字 时作者侧自动派单(由本机 host 执行并打印回执摘要)',
|
|
144
|
+
' dispatch <目标> <任务...> 等价发一条 "@!目标 任务" 并派单,打印回执摘要',
|
|
145
|
+
' accept on|off 切换本机接单(写 profile 配置,热生效)',
|
|
140
146
|
' history [--since N] 取历史消息 [--limit 1..200]',
|
|
141
147
|
' tail [--since N] 跟随事件流并逐条打印 [--duration 秒]',
|
|
142
148
|
' join [--name 显示名] 向 hub 自助加入(hub 开了 autoJoin 才成功)',
|
|
@@ -703,8 +709,68 @@ async function cmdGroupState(dir, args) {
|
|
|
703
709
|
console.log('groupKey: 已在 group-key.json(32B,不显示)');
|
|
704
710
|
}
|
|
705
711
|
|
|
706
|
-
|
|
707
|
-
|
|
712
|
+
/**
|
|
713
|
+
* 契约 §3 的回执摘要(CLI 口径,与面板同形):派单 → 目标:done/failed(reason / 耗时 / 字数)。
|
|
714
|
+
* skipped 是第三种终态:这条消息没有 @! 目标 —— 零消息写入,不能说成完成也不能说成失败。
|
|
715
|
+
*/
|
|
716
|
+
function formatDispatchLine(dispatch) {
|
|
717
|
+
const data = dispatch && typeof dispatch === 'object' ? dispatch : {};
|
|
718
|
+
const status = String(data.status ?? '');
|
|
719
|
+
const who = String(data.targetName ?? '') || String(data.target ?? '') || '—';
|
|
720
|
+
if (status === 'skipped') return '未派单(消息里没有 @! 目标): ' + String(data.reason ?? 'no-target');
|
|
721
|
+
const parts = [];
|
|
722
|
+
// done 也可能带 reason(契约 §1:多个 @! 只取第一个,reason=multi-target-ignored)。
|
|
723
|
+
const reason = String(data.reason ?? '');
|
|
724
|
+
if (reason !== '') parts.push(reason);
|
|
725
|
+
else if (status !== 'done') parts.push('error');
|
|
726
|
+
const elapsed = Number(data.elapsedMs);
|
|
727
|
+
if (Number.isFinite(elapsed) && elapsed >= 0) parts.push(Math.round(elapsed) + 'ms');
|
|
728
|
+
const chars = Number(data.replyChars);
|
|
729
|
+
if (Number.isFinite(chars) && chars >= 0) parts.push(Math.round(chars) + '字');
|
|
730
|
+
return '派单 → ' + who + ':' + (status === 'done' ? 'done' : 'failed')
|
|
731
|
+
+ (parts.length ? '(' + parts.join(' / ') + ')' : '');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* 契约 §5:作者侧派单的执行口 = 本机回环的 POST /api/lan-peers/group/dispatch。
|
|
736
|
+
* 派单要开 1:1 会话、要往群里写 progress/result,只有本机 host 做得到;CLI 直连 hub
|
|
737
|
+
* 发消息,所以这里必须再打一次本机管理接口。
|
|
738
|
+
*
|
|
739
|
+
* 本机没有 host(纯 CLI 节点、host 没起)→ **如实报错并 exit 2**:
|
|
740
|
+
* 静默把 "@!X 任务" 当成一条普普通通的群消息发出去,是最坏的一种失败。
|
|
741
|
+
*/
|
|
742
|
+
async function triggerDispatch(args, command) {
|
|
743
|
+
const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
|
|
744
|
+
let out;
|
|
745
|
+
try {
|
|
746
|
+
out = await adminCall(url, 'group/dispatch', {
|
|
747
|
+
method: 'POST',
|
|
748
|
+
headers: { 'Content-Type': 'application/json' },
|
|
749
|
+
body: JSON.stringify({
|
|
750
|
+
target: command.target,
|
|
751
|
+
task: command.task,
|
|
752
|
+
extraTargets: command.extraTargets,
|
|
753
|
+
}),
|
|
754
|
+
});
|
|
755
|
+
} catch (error) {
|
|
756
|
+
const reason = String(error?.message ?? error);
|
|
757
|
+
process.stderr.write('消息已发出,但派单未触发:本机 host 未响应 ' + url + '(' + reason + ')\n'
|
|
758
|
+
+ '修法:先起本机 host(或对 --url 指向的管理接口重试 group dispatch)\n');
|
|
759
|
+
process.exitCode = 2;
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const dispatch = out && out.dispatch && typeof out.dispatch === 'object' ? out.dispatch : null;
|
|
763
|
+
if (!dispatch) {
|
|
764
|
+
process.stderr.write('派单未返回结果:本机 host 未按契约回 dispatch 摘要\n');
|
|
765
|
+
process.exitCode = 2;
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
console.log(formatDispatchLine(dispatch));
|
|
769
|
+
if (dispatch.status === 'failed') process.exitCode = 1;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** 发一条群消息(post 与 dispatch 共用);作者侧在 POST 之后按契约 §1 解析 @! 并派单。 */
|
|
773
|
+
async function postGroupText(dir, args, text) {
|
|
708
774
|
if (!text) throw new Error(GROUP_USAGE);
|
|
709
775
|
if (Array.from(text).length > GROUP_TEXT_MAX_CHARS) {
|
|
710
776
|
throw new Error('text 超过 ' + GROUP_TEXT_MAX_CHARS + ' 字符上限(hub 会回 413 payload-too-large)');
|
|
@@ -726,6 +792,53 @@ async function cmdGroupPost(dir, args) {
|
|
|
726
792
|
'已发送 seq=' + sent.seq + ' ts=' + new Date(Number(sent.ts)).toISOString()
|
|
727
793
|
+ (channel.mode === 'group' ? '(群级签名直连 ' + channel.key.baseUrl + ')' : ''),
|
|
728
794
|
);
|
|
795
|
+
// 只有 chat 参与解析:progress/result 本身就是回执,再解析一遍等于自己派自己。
|
|
796
|
+
if (kind !== 'chat') return;
|
|
797
|
+
const command = parseGroupDispatchCommand(text);
|
|
798
|
+
if (!command) return;
|
|
799
|
+
await triggerDispatch(args, command);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function cmdGroupPost(dir, args) {
|
|
803
|
+
const text = String(args.text ?? args._.slice(1).join(' ') ?? '').trim();
|
|
804
|
+
await postGroupText(dir, args, text);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** 契约 §5:显式入口 —— 等价发一条 "@!目标 任务" 再派单(消息先入库,回执摘要打印出来)。 */
|
|
808
|
+
async function cmdGroupDispatch(dir, args) {
|
|
809
|
+
const target = String(args._[1] ?? '').trim();
|
|
810
|
+
const task = args._.slice(2).join(' ').trim() || String(args.text ?? '').trim();
|
|
811
|
+
if (target === '' || task === '') {
|
|
812
|
+
throw new Error('用法: dsh-lan-peers group dispatch <目标> <任务...>\n' + GROUP_USAGE);
|
|
813
|
+
}
|
|
814
|
+
await postGroupText(dir, args, '@!' + target + ' ' + task);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** 契约 §4/§5:切换本机接单开关(写 profile 配置、热生效;结果以 host 回的为准)。 */
|
|
818
|
+
async function cmdGroupAccept(dir, args) {
|
|
819
|
+
const wanted = String(args._[1] ?? '').trim().toLowerCase();
|
|
820
|
+
const value = wanted === 'on' ? true : (wanted === 'off' ? false : null);
|
|
821
|
+
if (value === null) {
|
|
822
|
+
throw new Error('用法: dsh-lan-peers group accept on|off\n' + GROUP_USAGE);
|
|
823
|
+
}
|
|
824
|
+
const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
|
|
825
|
+
let out;
|
|
826
|
+
try {
|
|
827
|
+
out = await adminCall(url, 'group/accept', {
|
|
828
|
+
method: 'POST',
|
|
829
|
+
headers: { 'Content-Type': 'application/json' },
|
|
830
|
+
body: JSON.stringify({ acceptDispatch: value }),
|
|
831
|
+
});
|
|
832
|
+
} catch (error) {
|
|
833
|
+
const reason = String(error?.message ?? error);
|
|
834
|
+
process.stderr.write('切换接单失败:本机 host 未接受 ' + url + '(' + reason + ')\n'
|
|
835
|
+
+ '修法:确认本机 host 在跑且群功能已启用(' + reason + ' 为 501 表示 host 还没接线)\n');
|
|
836
|
+
process.exitCode = /^(40[39]|501)\b/.test(reason) ? 2 : 1;
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const applied = !(out && out.acceptDispatch === false);
|
|
840
|
+
console.log('本机接单:' + (applied ? '接单中' : '已关闭')
|
|
841
|
+
+ (out && out.persisted === false ? '(仅本进程生效,重启后回到配置值)' : ''));
|
|
729
842
|
}
|
|
730
843
|
|
|
731
844
|
async function cmdGroupHistory(dir, args) {
|
|
@@ -1340,6 +1453,8 @@ async function cmdGroup(args) {
|
|
|
1340
1453
|
const handlers = {
|
|
1341
1454
|
state: cmdGroupState,
|
|
1342
1455
|
post: cmdGroupPost,
|
|
1456
|
+
dispatch: cmdGroupDispatch,
|
|
1457
|
+
accept: cmdGroupAccept,
|
|
1343
1458
|
history: cmdGroupHistory,
|
|
1344
1459
|
tail: cmdGroupTail,
|
|
1345
1460
|
join: cmdGroupJoin,
|