@xmanrui/dsh-im 4.25.0 → 4.27.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/PROACTIVE_DELIVERY.en.md +12 -4
- package/PROACTIVE_DELIVERY.md +12 -4
- package/README.en.md +5 -1
- package/README.md +5 -1
- package/lib/client.js +93 -33
- package/lib/index.js +284 -278
- package/package.json +4 -7
- package/plugin-src/client/global-settings.js +26 -14
- package/plugin-src/client/i18n.js +5 -0
- package/plugin-src/client/styles.js +14 -0
- package/plugin-src/client/update-panel.js +7 -0
- package/plugin-src/host/channels/matrix/production.mjs +14 -0
- package/plugin-src/host/delivery-rpc.mjs +7 -2
- package/plugin-src/host/delivery-service.mjs +8 -2
- package/scripts/verify-injected-context.mjs +120 -0
- package/scripts/verify-package.mjs +1 -0
- package/src/channels/feishu/feishu-runtime.mjs +13 -3
- package/src/channels/feishu/state-store.mjs +3 -2
- package/src/channels/matrix/matrix-config-store.mjs +14 -2
- package/src/channels/matrix/matrix-room-history.mjs +316 -0
- package/src/channels/matrix/matrix-runtime.mjs +134 -20
- package/src/channels/shared/i18n-en/image-input.mjs +1 -0
- package/src/channels/shared/i18n-en/matrix.mjs +10 -0
- package/src/channels/shared/injected-context.mjs +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.27.0",
|
|
4
4
|
"description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
"lib",
|
|
117
117
|
"plugin-src",
|
|
118
118
|
"scripts/verify-interface-language.mjs",
|
|
119
|
+
"scripts/verify-injected-context.mjs",
|
|
119
120
|
"scripts/verify-lan-management.mjs",
|
|
120
121
|
"scripts/verify-model-setting.mjs",
|
|
121
122
|
"scripts/verify-package.mjs",
|
|
@@ -145,13 +146,9 @@
|
|
|
145
146
|
"platform": "web"
|
|
146
147
|
},
|
|
147
148
|
"compatibility": {
|
|
148
|
-
"dsh": "0.1.
|
|
149
|
+
"dsh": "0.1.7-alpha.1",
|
|
149
150
|
"dshReleases": {
|
|
150
|
-
"0.1.
|
|
151
|
-
"0.1.2-alpha.5": "compatible",
|
|
152
|
-
"0.1.2-rc.1": "compatible",
|
|
153
|
-
"0.1.3-alpha.1": "compatible",
|
|
154
|
-
"0.1.5-alpha.1": "compatible"
|
|
151
|
+
"0.1.7-alpha.1": "compatible"
|
|
155
152
|
},
|
|
156
153
|
"profiles": [
|
|
157
154
|
"web"
|
|
@@ -118,21 +118,33 @@ export function ImageInputSettings({ rpcCall }) {
|
|
|
118
118
|
['maxTotalMb', '模型图片总量上限 (MB)'],
|
|
119
119
|
['maxImages', '每条消息最多图片数'],
|
|
120
120
|
];
|
|
121
|
-
return h('section', { className: 'dim-globalSection', 'aria-label': '图片输入', 'aria-busy': phase === 'loading' || phase === 'saving' },
|
|
122
|
-
h('
|
|
123
|
-
|
|
121
|
+
return h('section', { className: 'dim-globalSection dim-imageSettings', 'aria-label': '图片输入', 'aria-busy': phase === 'loading' || phase === 'saving' },
|
|
122
|
+
h('div', { className: 'dim-globalHead' },
|
|
123
|
+
h('div', { className: 'dim-globalHeadTitle' },
|
|
124
|
+
h('h3', null, '图片输入'),
|
|
125
|
+
h('div', { className: 'dim-globalTtlHelp' },
|
|
126
|
+
h('button', {
|
|
127
|
+
type: 'button',
|
|
128
|
+
className: 'dim-channelHelpButton dim-globalTtlHelpButton',
|
|
129
|
+
'aria-label': '查看图片输入说明',
|
|
130
|
+
'aria-describedby': `${id}-help`,
|
|
131
|
+
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
|
132
|
+
h('div', { id: `${id}-help`, className: 'dim-globalTtlTooltip dim-imageSettingsTooltip', role: 'tooltip' },
|
|
133
|
+
'适用于支持图片的聊天渠道。原图按附件保留时长保存,发送给模型的副本会自动缩放或压缩;无法直接发送时交给模型按文件处理。')))),
|
|
124
134
|
h('form', { onSubmit: save },
|
|
125
|
-
|
|
126
|
-
h('
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
h('div', { className: 'dim-imageSettingsFields' },
|
|
136
|
+
...fields.map(([key, label]) => h('div', { className: 'dim-imageSettingsField', key },
|
|
137
|
+
h('label', { htmlFor: `${id}-${key}` }, label),
|
|
138
|
+
h('input', { id: `${id}-${key}`, className: 'dim-globalTtlInput', type: 'number', min: 1, step: 1,
|
|
139
|
+
value: values[key], disabled: phase !== 'ready',
|
|
140
|
+
onChange: (event) => { setValues((current) => ({ ...current, [key]: event.target.value })); setSaved(false); setError(null); },
|
|
141
|
+
})))),
|
|
142
|
+
h('div', { className: 'dim-imageSettingsActions' },
|
|
143
|
+
h(GlobalButton, { type: 'submit', kind: 'primary', className: 'dim-globalSaveButton', disabled: phase !== 'ready' }, phase === 'saving' ? '保存中…' : '保存图片设置'),
|
|
144
|
+
phase === 'error' ? h(GlobalButton, { className: 'dim-globalSaveButton', onClick: () => void load() }, '重试') : null)),
|
|
145
|
+
error ? h('p', { className: 'dim-globalInline dim-imageSettingsFeedback', 'data-tone': 'error', role: 'alert' }, error) : null,
|
|
146
|
+
saved ? h('p', { className: 'dim-globalInline dim-imageSettingsFeedback', role: 'status' }, '已保存') : null,
|
|
147
|
+
phase === 'loading' ? h('p', { className: 'dim-globalInline dim-imageSettingsFeedback', role: 'status' }, '正在读取图片设置…') : null);
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
export function GlobalSettingsPanel({ rpcCall }) {
|
|
@@ -108,6 +108,11 @@ const EN = Object.freeze({
|
|
|
108
108
|
'当前版本': 'Current version',
|
|
109
109
|
'DSH-IM 更新': 'DSH-IM update',
|
|
110
110
|
'检查更新': 'Check for updates',
|
|
111
|
+
'DSH 版本兼容说明': 'DSH version compatibility',
|
|
112
|
+
'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。': 'dsh-im 4.25.0 supports DSH up to v0.1.6-alpha.2.',
|
|
113
|
+
'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。': 'dsh-im 4.26.0 only supports DSH v0.1.7-alpha.1.',
|
|
114
|
+
'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。': 'dsh-im 4.27.0 only supports DSH v0.1.7-alpha.1 and later.',
|
|
115
|
+
'升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。': 'Before updating to dsh-im 4.26.0, upgrade DSH to v0.1.7-alpha.1. If you use an older DSH version, stay on dsh-im 4.25.0.',
|
|
111
116
|
'重新检查': 'Check again',
|
|
112
117
|
'刷新状态': 'Refresh status',
|
|
113
118
|
'手工更新': 'Manual update',
|
|
@@ -613,6 +613,20 @@ const CSS = String.raw`
|
|
|
613
613
|
.dim-generalSettingsTab:focus-visible { outline: 2px solid var(--dim-blue); outline-offset: -2px; border-radius: 5px; }
|
|
614
614
|
.dim-generalSettingsTabPanel { min-width: 0; padding-top: 14px; }
|
|
615
615
|
.dim-globalSection { min-width: 0; padding: 14px 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-bg-layer-3, #fff); }
|
|
616
|
+
.dim-globalSection + .dim-globalSection { margin-top: 12px; }
|
|
617
|
+
.dim-imageSettings { container-type: inline-size; }
|
|
618
|
+
.dim-imageSettings .dim-globalHead { position: relative; }
|
|
619
|
+
.dim-imageSettings .dim-globalTtlHelp { position: static; }
|
|
620
|
+
.dim-globalTtlTooltip.dim-imageSettingsTooltip { width: min(320px, 100%); max-width: none; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; white-space: normal; overflow-wrap: anywhere; }
|
|
621
|
+
.dim-imageSettingsFields { min-width: 0; display: grid; gap: 12px; margin-top: 12px; }
|
|
622
|
+
.dim-imageSettingsField { min-width: 0; display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 160px); align-items: center; gap: 6px 16px; }
|
|
623
|
+
.dim-imageSettingsField label { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
|
|
624
|
+
.dim-imageSettingsField .dim-globalTtlInput { width: 100%; min-width: 0; }
|
|
625
|
+
.dim-imageSettingsActions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
|
626
|
+
.dim-globalInline.dim-imageSettingsFeedback { margin: 10px 0 0; }
|
|
627
|
+
@container (max-width: 380px) {
|
|
628
|
+
.dim-imageSettingsField { grid-template-columns: minmax(0, 1fr); }
|
|
629
|
+
}
|
|
616
630
|
.dim-globalHead { min-width: 0; display: flex; align-items: center; }
|
|
617
631
|
.dim-globalHeadTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
|
|
618
632
|
.dim-globalHead h3 { min-width: 0; overflow: hidden; margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; line-height: 22px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -467,6 +467,13 @@ export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
|
|
467
467
|
versionsDiffer && !restartRequired ? h('p', { className: 'dim-updateHint' }, '页面版本与运行版本不同,请手动刷新页面;若仍不一致,请手动重启 Harness 或 Desktop。') : null,
|
|
468
468
|
canConfirm ? h('p', { className: 'dim-updateHint' },
|
|
469
469
|
'请在机器人空闲时安装;安装会修改当前 profile 的依赖,完成后需手动重启。') : null,
|
|
470
|
+
h('aside', { className: 'dim-updateHint', 'aria-label': 'DSH 版本兼容说明' },
|
|
471
|
+
h('strong', null, 'DSH 版本兼容说明'),
|
|
472
|
+
h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。'),
|
|
473
|
+
h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。'),
|
|
474
|
+
h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。'),
|
|
475
|
+
h('p', { className: 'dim-updateManualHint' },
|
|
476
|
+
'升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。')),
|
|
470
477
|
h(ManualUpdateCommand, {
|
|
471
478
|
key: manualCommand ?? 'unavailable', command: manualCommand,
|
|
472
479
|
disabled: busyAction || activeJob || uncertainInstall,
|
|
@@ -5,6 +5,7 @@ import { MatrixConfigStore, MatrixSidecarStore } from '../../../../src/channels/
|
|
|
5
5
|
import { MatrixController } from '../../../../src/channels/matrix/matrix-controller.mjs';
|
|
6
6
|
import { MatrixCryptoStore, matrixCryptoPathFor } from '../../../../src/channels/matrix/matrix-crypto-store.mjs';
|
|
7
7
|
import { MatrixHarnessClient } from '../../../../src/channels/matrix/matrix-harness-client.mjs';
|
|
8
|
+
import { MatrixRoomHistoryStore, matrixRoomHistoryPathFor } from '../../../../src/channels/matrix/matrix-room-history.mjs';
|
|
8
9
|
import { MatrixRuntime } from '../../../../src/channels/matrix/matrix-runtime.mjs';
|
|
9
10
|
import { ConversationStateStore } from '../../../../src/channels/shared/conversation-state-store.mjs';
|
|
10
11
|
import {
|
|
@@ -63,10 +64,13 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
63
64
|
const stateStores = new Map();
|
|
64
65
|
const sidecarStores = new Map();
|
|
65
66
|
const cryptoStores = new Map();
|
|
67
|
+
const historyStores = new Map();
|
|
68
|
+
const ResolvedRoomHistory = internals.RoomHistoryStore ?? MatrixRoomHistoryStore;
|
|
66
69
|
const ResolvedCryptoStore = internals.CryptoStore ?? MatrixCryptoStore;
|
|
67
70
|
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
|
|
68
71
|
const sidecarPath = (botId) => resolve(paths.bots, botId, 'matrix.json');
|
|
69
72
|
const cryptoPath = (botId) => matrixCryptoPathFor(resolve(paths.bots, botId));
|
|
73
|
+
const historyPath = (botId) => matrixRoomHistoryPathFor(resolve(paths.bots, botId));
|
|
70
74
|
const stateFor = async (botId) => {
|
|
71
75
|
let state = stateStores.get(botId);
|
|
72
76
|
if (!state) {
|
|
@@ -91,6 +95,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
91
95
|
}
|
|
92
96
|
return crypto;
|
|
93
97
|
};
|
|
98
|
+
const historyFor = async (botId) => {
|
|
99
|
+
let history = historyStores.get(botId);
|
|
100
|
+
if (!history) {
|
|
101
|
+
history = await new ResolvedRoomHistory(historyPath(botId)).load();
|
|
102
|
+
historyStores.set(botId, history);
|
|
103
|
+
}
|
|
104
|
+
return history;
|
|
105
|
+
};
|
|
94
106
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
95
107
|
const inboundTtl = internals.inboundTtl ?? getInboundTtlRuntime(ctx, config);
|
|
96
108
|
const inboundTtlService = inboundTtl?.service ?? inboundTtl;
|
|
@@ -137,6 +149,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
137
149
|
});
|
|
138
150
|
const e2eeMode = String(botConfig?.e2eeMode ?? 'optional').trim().toLowerCase();
|
|
139
151
|
const cryptoStore = e2eeMode === 'off' ? null : await cryptoFor(botId);
|
|
152
|
+
const roomHistory = await historyFor(botId);
|
|
140
153
|
return new ResolvedRuntime({
|
|
141
154
|
config: botConfig,
|
|
142
155
|
...(accessToken ? { accessToken } : {}),
|
|
@@ -145,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
145
158
|
harness: workspaceScope.harness,
|
|
146
159
|
state: workspaceScope.state,
|
|
147
160
|
sidecar,
|
|
161
|
+
roomHistory,
|
|
148
162
|
...(cryptoStore ? { cryptoStore } : {}),
|
|
149
163
|
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
|
150
164
|
accessPolicy: accessPolicyProvider(workspaces, botId, {
|
|
@@ -65,7 +65,9 @@ function validDraftTarget(value) {
|
|
|
65
65
|
function validPayload(endpoint, payload) {
|
|
66
66
|
if (!ENDPOINTS.has(endpoint) || !isRecord(payload)) return false;
|
|
67
67
|
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
|
68
|
-
return exactKeys(payload, ['botId', 'targetId', 'text'])
|
|
68
|
+
return (exactKeys(payload, ['botId', 'targetId', 'text'])
|
|
69
|
+
|| (exactKeys(payload, ['botId', 'targetId', 'text', 'format'])
|
|
70
|
+
&& ['plain', 'markdown'].includes(payload.format)))
|
|
69
71
|
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
|
70
72
|
&& typeof payload.text === 'string' && Boolean(payload.text.trim());
|
|
71
73
|
}
|
|
@@ -128,7 +130,10 @@ export function createDeliveryRpcHandler(service) {
|
|
|
128
130
|
try {
|
|
129
131
|
let value;
|
|
130
132
|
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
|
131
|
-
value = await service.send(payload.botId, payload.targetId, payload.text, {
|
|
133
|
+
value = await service.send(payload.botId, payload.targetId, payload.text, {
|
|
134
|
+
signal,
|
|
135
|
+
...(payload.format === undefined ? {} : { format: payload.format }),
|
|
136
|
+
});
|
|
132
137
|
} else if (endpoint === DELIVERY_ENDPOINTS.listTargets) {
|
|
133
138
|
value = await service.listTargets(payload.botId);
|
|
134
139
|
} else if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
|
@@ -290,7 +290,7 @@ export class DeliveryService {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
async send(botId, targetIdOrDraft, text, { signal } = {}) {
|
|
293
|
+
async send(botId, targetIdOrDraft, text, { signal, format = 'plain' } = {}) {
|
|
294
294
|
const id = botIdOf(botId);
|
|
295
295
|
const targetKey = typeof targetIdOrDraft === 'string'
|
|
296
296
|
? targetIdOf(targetIdOrDraft)
|
|
@@ -299,6 +299,9 @@ export class DeliveryService {
|
|
|
299
299
|
if (typeof text !== 'string' || !text.trim()) {
|
|
300
300
|
throw deliveryError('bad-request', 'Message text is required');
|
|
301
301
|
}
|
|
302
|
+
if (format !== 'plain' && format !== 'markdown') {
|
|
303
|
+
throw deliveryError('bad-request', 'Message format must be plain or markdown');
|
|
304
|
+
}
|
|
302
305
|
cancellation(signal);
|
|
303
306
|
const adapter = await this.#adapterFor(id);
|
|
304
307
|
try {
|
|
@@ -312,7 +315,10 @@ export class DeliveryService {
|
|
|
312
315
|
if (!target) throw deliveryError('unknown-target', 'Unknown target');
|
|
313
316
|
}
|
|
314
317
|
cancellation(signal);
|
|
315
|
-
await adapter.sendText(id, target, text, {
|
|
318
|
+
await adapter.sendText(id, target, text, {
|
|
319
|
+
signal,
|
|
320
|
+
...(format === 'markdown' ? { format } : {}),
|
|
321
|
+
});
|
|
316
322
|
return { sent: true };
|
|
317
323
|
} catch (error) {
|
|
318
324
|
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Verify the built Host hook against a built DSH's real JSONL persistence. */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { createImHostPlugin } from '../lib/index.js';
|
|
10
|
+
import {
|
|
11
|
+
captureContextEnhancement, enhanceContextContent, INJECTED_CONTEXT_TAGS,
|
|
12
|
+
} from '../src/channels/shared/context-enhancement.mjs';
|
|
13
|
+
|
|
14
|
+
if (!process.argv[2]) throw new Error('Pass the path of a built DSH checkout.');
|
|
15
|
+
const harnessRoot = resolve(process.argv[2]);
|
|
16
|
+
const fromHarness = createRequire(resolve(harnessRoot, 'packages/session/session-persistence-jsonl/package.json'));
|
|
17
|
+
const load = (name) => import(pathToFileURL(fromHarness.resolve(name)).href);
|
|
18
|
+
const { Context } = await load('@deepseek-ai/cordis');
|
|
19
|
+
const { SESSION_FORMAT_VERSION } = await load('@deepseek-ai/dsh-session');
|
|
20
|
+
const { default: JsonlSessionPersistence } = await load('@deepseek-ai/dsh-session-persistence-jsonl');
|
|
21
|
+
assert.equal(SESSION_FORMAT_VERSION, 4, 'This regression verifies Session format V4.');
|
|
22
|
+
|
|
23
|
+
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-context-'));
|
|
24
|
+
const ctx = new Context();
|
|
25
|
+
const source = { channel: 'feishu', senderName: 'Context test' };
|
|
26
|
+
const guidance = 'Reply briefly.';
|
|
27
|
+
const reply = `${INJECTED_CONTEXT_TAGS.replyOpen}${JSON.stringify({
|
|
28
|
+
authorName: 'Quoted author', content: 'Quoted text',
|
|
29
|
+
})}${INJECTED_CONTEXT_TAGS.replyClose}`;
|
|
30
|
+
|
|
31
|
+
function enhanced(content, fields = ['channel', 'senderName'], instructions = '') {
|
|
32
|
+
const snapshot = captureContextEnhancement({
|
|
33
|
+
botId: 'context-test',
|
|
34
|
+
getSettings: () => ({
|
|
35
|
+
group: { enabled: true, fields, guidance: instructions },
|
|
36
|
+
direct: { enabled: false, fields: [], guidance: '' },
|
|
37
|
+
}),
|
|
38
|
+
}, 'group');
|
|
39
|
+
return enhanceContextContent(content, snapshot, () => source);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function turnEvents(messages, turn = 1, offset = 0) {
|
|
43
|
+
return [
|
|
44
|
+
{ type: 'turn/start', data: { turn } },
|
|
45
|
+
{ type: 'step/start', data: { turn, step: 1 } },
|
|
46
|
+
...messages.map(data => ({ type: 'user/message', data, surfaceOp: 'append' })),
|
|
47
|
+
{ type: 'step/end', data: { turn, step: 1 } },
|
|
48
|
+
{ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } },
|
|
49
|
+
].map((event, index) => ({ ...event, seq: offset + index, time: offset + index + 1 }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cases = [
|
|
53
|
+
['plain', 'Question', ['user']],
|
|
54
|
+
['source', enhanced('Question'), ['user', 'notice']],
|
|
55
|
+
['guidance', enhanced('Question', [], guidance), ['user', 'instructions']],
|
|
56
|
+
['quote', `${reply}\n\nQuestion`, ['notice', 'user']],
|
|
57
|
+
['combined', enhanced(`${reply}\n\nQuestion`, ['channel'], guidance),
|
|
58
|
+
['notice', 'user', 'notice', 'instructions']],
|
|
59
|
+
['structured', enhanced([{ type: 'text', text: reply }, { type: 'text', text: 'Question' }]),
|
|
60
|
+
['notice', 'user', 'notice']],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await ctx.plugin(JsonlSessionPersistence, { root: directory, compression: 'none' });
|
|
65
|
+
for (const [name, value] of Object.entries({
|
|
66
|
+
connection: {}, credentials: {}, typertGateway: { stream() {} },
|
|
67
|
+
sessionController: {}, workspaceController: {},
|
|
68
|
+
})) ctx.provide(name, value);
|
|
69
|
+
// Keep the real bundled context hook; unrelated channel connections stay idle.
|
|
70
|
+
const internals = Object.fromEntries([
|
|
71
|
+
'Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'WecomApp', 'Qq', 'Slack',
|
|
72
|
+
'Telegram', 'Discord', 'Whatsapp', 'IMessage', 'Email', 'Matrix', 'Office',
|
|
73
|
+
].map(name => [`apply${name}`, async () => {}]));
|
|
74
|
+
const host = ctx.plugin(createImHostPlugin({
|
|
75
|
+
...internals,
|
|
76
|
+
installHostLanguage: () => {},
|
|
77
|
+
installSessionSyncCoordinator: () => {},
|
|
78
|
+
}));
|
|
79
|
+
await host.await();
|
|
80
|
+
|
|
81
|
+
for (const [id, content, expectedForms] of cases) {
|
|
82
|
+
const message = {
|
|
83
|
+
id: `${id}-user`, role: 'user',
|
|
84
|
+
content: typeof content === 'string' ? [{ type: 'text', text: content }] : content,
|
|
85
|
+
source: { kind: 'user', rpcId: `feishu-${id}` },
|
|
86
|
+
};
|
|
87
|
+
const decision = { kind: 'enter', messages: [message] };
|
|
88
|
+
const result = await ctx.waterfall('agent/pre-step', { agent: { session: { id } } },
|
|
89
|
+
async () => decision);
|
|
90
|
+
assert.deepEqual(result.messages.map(entry => entry.source.kind === 'user' ? 'user' : entry.source.form), expectedForms, id);
|
|
91
|
+
const human = result.messages.find(entry => entry.source.kind === 'user');
|
|
92
|
+
assert.equal(human.id, message.id);
|
|
93
|
+
assert.deepEqual(human.source, message.source);
|
|
94
|
+
assert.deepEqual(human.content, [{ type: 'text', text: 'Question' }]);
|
|
95
|
+
for (const context of result.messages.filter(entry => entry !== human)) {
|
|
96
|
+
assert.equal(context.source.kind, 'plugin:dsh-im', id);
|
|
97
|
+
assert.equal(Object.hasOwn(context.source, 'plugin'), false, id);
|
|
98
|
+
if (context.source.form === 'notice') assert.ok(context.source.summary.length > 0, id);
|
|
99
|
+
}
|
|
100
|
+
assert.equal(await ctx.waterfall('agent/pre-step', { agent: { session: { id } } }, async () => result), result);
|
|
101
|
+
|
|
102
|
+
const header = { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, delegationDepth: 0 };
|
|
103
|
+
const events = turnEvents(result.messages);
|
|
104
|
+
const writer = await ctx.sessionPersistence.create(header);
|
|
105
|
+
try { await writer.append(events); } finally { await writer.close(); }
|
|
106
|
+
// Reopen for a second turn, then verify both turns through a fresh read handle.
|
|
107
|
+
const resumed = await ctx.sessionPersistence.open(id, 'write');
|
|
108
|
+
const second = turnEvents([{ ...human, id: `${id}-second` }], 2, events.length);
|
|
109
|
+
try { await resumed.append(second); } finally { await resumed.close(); }
|
|
110
|
+
const reader = await ctx.sessionPersistence.open(id, 'read');
|
|
111
|
+
try {
|
|
112
|
+
assert.deepEqual((await reader.read()).events, [...events, ...second], id);
|
|
113
|
+
} finally { await reader.close(); }
|
|
114
|
+
console.log(`PASS ${id}: bundled hook, persistence, reopen and next turn`);
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
try { await ctx.fiber.dispose(); }
|
|
118
|
+
finally { await rm(directory, { recursive: true, force: true }); }
|
|
119
|
+
}
|
|
120
|
+
console.log(`Injected context verified against ${harnessRoot} (format V4).`);
|
|
@@ -628,7 +628,7 @@ export class FeishuRuntime {
|
|
|
628
628
|
});
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
-
async sendProactiveText(target, text, { signal } = {}) {
|
|
631
|
+
async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
632
632
|
if (!this.#status.ready || !this.#client) {
|
|
633
633
|
const error = new Error('飞书机器人尚未连接');
|
|
634
634
|
error.code = 'bot-not-connected';
|
|
@@ -646,13 +646,23 @@ export class FeishuRuntime {
|
|
|
646
646
|
error.code = 'invalid-target';
|
|
647
647
|
throw error;
|
|
648
648
|
}
|
|
649
|
+
if (format !== 'plain' && format !== 'markdown') {
|
|
650
|
+
const error = new TypeError('Message format must be plain or markdown');
|
|
651
|
+
error.code = 'bad-request';
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
649
654
|
signal?.throwIfAborted();
|
|
655
|
+
// Use the same native Markdown element as chat, without opening a stream
|
|
656
|
+
// or retrying as plain text after a possibly accepted delivery.
|
|
657
|
+
const content = format === 'markdown'
|
|
658
|
+
? { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text }] } }
|
|
659
|
+
: { text };
|
|
650
660
|
const response = await this.#client.im.v1.message.create({
|
|
651
661
|
params: { receive_id_type: receiveIdType },
|
|
652
662
|
data: {
|
|
653
663
|
receive_id: receiveId,
|
|
654
|
-
msg_type: 'text',
|
|
655
|
-
content: JSON.stringify(
|
|
664
|
+
msg_type: format === 'markdown' ? 'interactive' : 'text',
|
|
665
|
+
content: JSON.stringify(content),
|
|
656
666
|
},
|
|
657
667
|
});
|
|
658
668
|
if (response?.code && response.code !== 0) {
|
|
@@ -202,12 +202,13 @@ export class StateStore {
|
|
|
202
202
|
|
|
203
203
|
async #persist() {
|
|
204
204
|
const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
|
|
205
|
-
|
|
205
|
+
const operation = this.#writeQueue.then(async () => {
|
|
206
206
|
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
207
207
|
const temporary = `${this.#path}.tmp`;
|
|
208
208
|
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
209
209
|
await rename(temporary, this.#path);
|
|
210
210
|
});
|
|
211
|
-
|
|
211
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
212
|
+
await operation;
|
|
212
213
|
}
|
|
213
214
|
}
|
|
@@ -13,6 +13,9 @@ const E2EE_MODES = new Set(['off', 'optional', 'required']);
|
|
|
13
13
|
const AUTO_JOIN_MODES = new Set(['authorized', 'all']);
|
|
14
14
|
const MAX_MESSAGE_LENGTH_BOUNDS = Object.freeze({ min: 500, max: 65_535 });
|
|
15
15
|
const MAX_MEDIA_BYTES_CEILING = 104_857_600;
|
|
16
|
+
const ROOM_CONTEXT_LIMIT_BOUNDS = Object.freeze({ min: 1, max: 500 });
|
|
17
|
+
const ROOM_CONTEXT_CHARS_BOUNDS = Object.freeze({ min: 200, max: 200_000 });
|
|
18
|
+
const ROOM_CONTEXT_TZ_BOUNDS = Object.freeze({ min: -840, max: 840 });
|
|
16
19
|
|
|
17
20
|
function cleanString(value) {
|
|
18
21
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -179,10 +182,15 @@ export class MatrixConfigStore {
|
|
|
179
182
|
const allowedRooms = roomIdList(value.allowedRooms);
|
|
180
183
|
const freeResponseRooms = roomIdList(value.freeResponseRooms);
|
|
181
184
|
const ignoreUserPatterns = patternList(value.ignoreUserPatterns);
|
|
185
|
+
const roomContextLimit = boundedInteger(value.roomContextLimit, ROOM_CONTEXT_LIMIT_BOUNDS);
|
|
186
|
+
const roomContextMaxChars = boundedInteger(value.roomContextMaxChars, ROOM_CONTEXT_CHARS_BOUNDS);
|
|
187
|
+
const roomContextTzOffsetMinutes = boundedInteger(value.roomContextTzOffsetMinutes, ROOM_CONTEXT_TZ_BOUNDS);
|
|
182
188
|
if (!E2EE_MODES.has(e2eeMode) || !AUTO_JOIN_MODES.has(autoJoinInvites)
|
|
183
189
|
|| maxMessageLength === null || maxMediaBytes === null
|
|
184
|
-
|| allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null
|
|
185
|
-
|
|
190
|
+
|| allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null
|
|
191
|
+
|| roomContextLimit === null || roomContextMaxChars === null
|
|
192
|
+
|| roomContextTzOffsetMinutes === null) return null;
|
|
193
|
+
for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions', 'roomContextEnabled']) {
|
|
186
194
|
if (value?.[key] !== undefined && typeof value[key] !== 'boolean') return null;
|
|
187
195
|
}
|
|
188
196
|
return Object.freeze({
|
|
@@ -208,6 +216,10 @@ export class MatrixConfigStore {
|
|
|
208
216
|
...(allowedRooms !== undefined ? { allowedRooms: Object.freeze(allowedRooms) } : {}),
|
|
209
217
|
...(freeResponseRooms !== undefined ? { freeResponseRooms: Object.freeze(freeResponseRooms) } : {}),
|
|
210
218
|
...(ignoreUserPatterns !== undefined ? { ignoreUserPatterns: Object.freeze(ignoreUserPatterns) } : {}),
|
|
219
|
+
...(typeof value.roomContextEnabled === 'boolean' ? { roomContextEnabled: value.roomContextEnabled } : {}),
|
|
220
|
+
...(roomContextLimit !== undefined ? { roomContextLimit } : {}),
|
|
221
|
+
...(roomContextMaxChars !== undefined ? { roomContextMaxChars } : {}),
|
|
222
|
+
...(roomContextTzOffsetMinutes !== undefined ? { roomContextTzOffsetMinutes } : {}),
|
|
211
223
|
});
|
|
212
224
|
}
|
|
213
225
|
|