@barryfan2045/dsh-matrix 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 +286 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/assets/avatars/instance-01.png +0 -0
- package/assets/avatars/instance-02.png +0 -0
- package/assets/avatars/instance-03.png +0 -0
- package/assets/avatars/instance-04.png +0 -0
- package/assets/avatars/instance-05.png +0 -0
- package/assets/avatars/instance-06.png +0 -0
- package/assets/avatars/instance-07.png +0 -0
- package/assets/avatars/instance-08.png +0 -0
- package/assets/avatars/instance-09.png +0 -0
- package/assets/avatars/instance-10.png +0 -0
- package/assets/avatars/instance-11.png +0 -0
- package/assets/avatars/instance-12.png +0 -0
- package/assets/avatars/management.png +0 -0
- package/cordis.patch.yml +13 -0
- package/dist/auth/admin-commands.d.ts +47 -0
- package/dist/auth/admin-commands.d.ts.map +1 -0
- package/dist/auth/admin-commands.js +112 -0
- package/dist/auth/admin-commands.js.map +1 -0
- package/dist/auth/challenge.d.ts +93 -0
- package/dist/auth/challenge.d.ts.map +1 -0
- package/dist/auth/challenge.js +104 -0
- package/dist/auth/challenge.js.map +1 -0
- package/dist/auth/store.d.ts +55 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +124 -0
- package/dist/auth/store.js.map +1 -0
- package/dist/bridge/agent-commands.d.ts +39 -0
- package/dist/bridge/agent-commands.d.ts.map +1 -0
- package/dist/bridge/agent-commands.js +243 -0
- package/dist/bridge/agent-commands.js.map +1 -0
- package/dist/bridge/attachments-ledger.d.ts +54 -0
- package/dist/bridge/attachments-ledger.d.ts.map +1 -0
- package/dist/bridge/attachments-ledger.js +68 -0
- package/dist/bridge/attachments-ledger.js.map +1 -0
- package/dist/bridge/commands.d.ts +28 -0
- package/dist/bridge/commands.d.ts.map +1 -0
- package/dist/bridge/commands.js +18 -0
- package/dist/bridge/commands.js.map +1 -0
- package/dist/bridge/crypto-health.d.ts +37 -0
- package/dist/bridge/crypto-health.d.ts.map +1 -0
- package/dist/bridge/crypto-health.js +38 -0
- package/dist/bridge/crypto-health.js.map +1 -0
- package/dist/bridge/group-gate.d.ts +38 -0
- package/dist/bridge/group-gate.d.ts.map +1 -0
- package/dist/bridge/group-gate.js +49 -0
- package/dist/bridge/group-gate.js.map +1 -0
- package/dist/bridge/instance-lock.d.ts +10 -0
- package/dist/bridge/instance-lock.d.ts.map +1 -0
- package/dist/bridge/instance-lock.js +82 -0
- package/dist/bridge/instance-lock.js.map +1 -0
- package/dist/bridge/interactions.d.ts +62 -0
- package/dist/bridge/interactions.d.ts.map +1 -0
- package/dist/bridge/interactions.js +237 -0
- package/dist/bridge/interactions.js.map +1 -0
- package/dist/bridge/management-room.d.ts +13 -0
- package/dist/bridge/management-room.d.ts.map +1 -0
- package/dist/bridge/management-room.js +27 -0
- package/dist/bridge/management-room.js.map +1 -0
- package/dist/bridge/multi-project.d.ts +34 -0
- package/dist/bridge/multi-project.d.ts.map +1 -0
- package/dist/bridge/multi-project.js +44 -0
- package/dist/bridge/multi-project.js.map +1 -0
- package/dist/bridge/pipeline.d.ts +151 -0
- package/dist/bridge/pipeline.d.ts.map +1 -0
- package/dist/bridge/pipeline.js +502 -0
- package/dist/bridge/pipeline.js.map +1 -0
- package/dist/bridge/pmctl.d.ts +50 -0
- package/dist/bridge/pmctl.d.ts.map +1 -0
- package/dist/bridge/pmctl.js +272 -0
- package/dist/bridge/pmctl.js.map +1 -0
- package/dist/bridge/power-levels.d.ts +57 -0
- package/dist/bridge/power-levels.d.ts.map +1 -0
- package/dist/bridge/power-levels.js +92 -0
- package/dist/bridge/power-levels.js.map +1 -0
- package/dist/bridge/power-sync.d.ts +64 -0
- package/dist/bridge/power-sync.d.ts.map +1 -0
- package/dist/bridge/power-sync.js +121 -0
- package/dist/bridge/power-sync.js.map +1 -0
- package/dist/bridge/projects.d.ts +97 -0
- package/dist/bridge/projects.d.ts.map +1 -0
- package/dist/bridge/projects.js +288 -0
- package/dist/bridge/projects.js.map +1 -0
- package/dist/bridge/quote-cache.d.ts +38 -0
- package/dist/bridge/quote-cache.d.ts.map +1 -0
- package/dist/bridge/quote-cache.js +56 -0
- package/dist/bridge/quote-cache.js.map +1 -0
- package/dist/bridge/reply.d.ts +31 -0
- package/dist/bridge/reply.d.ts.map +1 -0
- package/dist/bridge/reply.js +122 -0
- package/dist/bridge/reply.js.map +1 -0
- package/dist/bridge/space-identity.d.ts +43 -0
- package/dist/bridge/space-identity.d.ts.map +1 -0
- package/dist/bridge/space-identity.js +92 -0
- package/dist/bridge/space-identity.js.map +1 -0
- package/dist/bridge/space-sync.d.ts +94 -0
- package/dist/bridge/space-sync.d.ts.map +1 -0
- package/dist/bridge/space-sync.js +137 -0
- package/dist/bridge/space-sync.js.map +1 -0
- package/dist/bridge/turn-commands.d.ts +28 -0
- package/dist/bridge/turn-commands.d.ts.map +1 -0
- package/dist/bridge/turn-commands.js +97 -0
- package/dist/bridge/turn-commands.js.map +1 -0
- package/dist/cli/config-store.d.ts +32 -0
- package/dist/cli/config-store.d.ts.map +1 -0
- package/dist/cli/config-store.js +118 -0
- package/dist/cli/config-store.js.map +1 -0
- package/dist/cli/crypto-store.d.ts +42 -0
- package/dist/cli/crypto-store.d.ts.map +1 -0
- package/dist/cli/crypto-store.js +70 -0
- package/dist/cli/crypto-store.js.map +1 -0
- package/dist/cli/device-prune.d.ts +105 -0
- package/dist/cli/device-prune.d.ts.map +1 -0
- package/dist/cli/device-prune.js +163 -0
- package/dist/cli/device-prune.js.map +1 -0
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +584 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/log-filter.d.ts +32 -0
- package/dist/cli/log-filter.d.ts.map +1 -0
- package/dist/cli/log-filter.js +40 -0
- package/dist/cli/log-filter.js.map +1 -0
- package/dist/cli/pnpm-builds.d.ts +32 -0
- package/dist/cli/pnpm-builds.d.ts.map +1 -0
- package/dist/cli/pnpm-builds.js +73 -0
- package/dist/cli/pnpm-builds.js.map +1 -0
- package/dist/cli/profile-patch.d.ts +26 -0
- package/dist/cli/profile-patch.d.ts.map +1 -0
- package/dist/cli/profile-patch.js +56 -0
- package/dist/cli/profile-patch.js.map +1 -0
- package/dist/cli/setup.d.ts +39 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +234 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/systemd-hint.d.ts +11 -0
- package/dist/cli/systemd-hint.d.ts.map +1 -0
- package/dist/cli/systemd-hint.js +46 -0
- package/dist/cli/systemd-hint.js.map +1 -0
- package/dist/cli/systemd.d.ts +32 -0
- package/dist/cli/systemd.d.ts.map +1 -0
- package/dist/cli/systemd.js +58 -0
- package/dist/cli/systemd.js.map +1 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +107 -0
- package/dist/config.js.map +1 -0
- package/dist/dsh/backend.d.ts +110 -0
- package/dist/dsh/backend.d.ts.map +1 -0
- package/dist/dsh/backend.js +481 -0
- package/dist/dsh/backend.js.map +1 -0
- package/dist/dsh/interactions.d.ts +50 -0
- package/dist/dsh/interactions.d.ts.map +1 -0
- package/dist/dsh/interactions.js +141 -0
- package/dist/dsh/interactions.js.map +1 -0
- package/dist/dsh/stream.d.ts +32 -0
- package/dist/dsh/stream.d.ts.map +1 -0
- package/dist/dsh/stream.js +109 -0
- package/dist/dsh/stream.js.map +1 -0
- package/dist/formatting.d.ts +14 -0
- package/dist/formatting.d.ts.map +1 -0
- package/dist/formatting.js +63 -0
- package/dist/formatting.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +232 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +28 -0
- package/dist/logger.js.map +1 -0
- package/dist/matrix/matrix-events.d.ts +49 -0
- package/dist/matrix/matrix-events.d.ts.map +1 -0
- package/dist/matrix/matrix-events.js +89 -0
- package/dist/matrix/matrix-events.js.map +1 -0
- package/dist/matrix/transport.d.ts +192 -0
- package/dist/matrix/transport.d.ts.map +1 -0
- package/dist/matrix/transport.js +510 -0
- package/dist/matrix/transport.js.map +1 -0
- package/dist/ports/attachments.d.ts +51 -0
- package/dist/ports/attachments.d.ts.map +1 -0
- package/dist/ports/attachments.js +27 -0
- package/dist/ports/attachments.js.map +1 -0
- package/dist/ports/backend.d.ts +159 -0
- package/dist/ports/backend.d.ts.map +1 -0
- package/dist/ports/backend.js +25 -0
- package/dist/ports/backend.js.map +1 -0
- package/dist/ports/interactions.d.ts +65 -0
- package/dist/ports/interactions.d.ts.map +1 -0
- package/dist/ports/interactions.js +15 -0
- package/dist/ports/interactions.js.map +1 -0
- package/dist/ports/streaming.d.ts +45 -0
- package/dist/ports/streaming.d.ts.map +1 -0
- package/dist/ports/streaming.js +23 -0
- package/dist/ports/streaming.js.map +1 -0
- package/dist/ports/transport.d.ts +68 -0
- package/dist/ports/transport.d.ts.map +1 -0
- package/dist/ports/transport.js +2 -0
- package/dist/ports/transport.js.map +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge pipeline core - the single test seam (spec: Testing Decisions).
|
|
3
|
+
*
|
|
4
|
+
* T1 tracer scope: trust gate + per-room session lazily + prompt/steer +
|
|
5
|
+
* turn-complete reply with 4000-char chunking, serialized per room.
|
|
6
|
+
* T3 adds the turn-control command table (/stop /queue /interrupt /last)
|
|
7
|
+
* between the trust gate and the prompt/steer split (src/bridge/turn-commands.ts).
|
|
8
|
+
* Later tickets extend this file (attachments, questions, groups).
|
|
9
|
+
* T2 adds the auth stage: static ∪ runtime trust list, DM challenge flow for
|
|
10
|
+
* strangers, and the admin command table (/trusted, /revoke).
|
|
11
|
+
* Later tickets extend this file (turn control, attachments, questions, groups).
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, Logger } from '../types.ts';
|
|
14
|
+
import type { Transport } from '../ports/transport.ts';
|
|
15
|
+
import type { Backend } from '../ports/backend.ts';
|
|
16
|
+
import type { TurnStreamSource } from '../ports/streaming.ts';
|
|
17
|
+
import type { AuthStore } from '../auth/store.ts';
|
|
18
|
+
import type { ChallengeEngine } from '../auth/challenge.ts';
|
|
19
|
+
/** Matrix text message hard limit; split replies beneath it. */
|
|
20
|
+
export declare const MAX_CHUNK = 4000;
|
|
21
|
+
/** 会话被占用时的第一次通知(单点文案,便于以后统一改口吻/多语言)。 */
|
|
22
|
+
export declare const SESSION_BUSY_NOTICE: string;
|
|
23
|
+
/**
|
|
24
|
+
* 重试预算耗尽后的可操作说明(US42:失败不许静默)。
|
|
25
|
+
*
|
|
26
|
+
* `waitedMs` 是桥真正花掉的**重试窗口**(retries × 每次间隔),不是墙上时钟:
|
|
27
|
+
* 实测里通知本身还要走一次 Matrix 发送(约 4 秒),那不属于重试。宁可如实说
|
|
28
|
+
* 重试了多久,也不要印一个凑出来的数字。
|
|
29
|
+
*/
|
|
30
|
+
export declare function sessionBusyGiveUpReply(waitedMs: number): string;
|
|
31
|
+
/** 默认重试节奏:每 5 秒一次,共 12 次 = 60 秒重试窗口。 */
|
|
32
|
+
export declare const SESSION_BUSY_RETRY_MS = 5000;
|
|
33
|
+
export declare const SESSION_BUSY_RETRIES = 12;
|
|
34
|
+
export interface BridgeAuthDeps {
|
|
35
|
+
store: AuthStore;
|
|
36
|
+
challenge: ChallengeEngine;
|
|
37
|
+
}
|
|
38
|
+
export interface BridgeDeps {
|
|
39
|
+
transport: Transport;
|
|
40
|
+
backend: Backend;
|
|
41
|
+
logger: Logger;
|
|
42
|
+
/** Static trust list from the row config (boot snapshot). */
|
|
43
|
+
trustedUsers: Iterable<string>;
|
|
44
|
+
/** Runtime trust + challenge engine (T2). */
|
|
45
|
+
auth: BridgeAuthDeps;
|
|
46
|
+
workdir: string;
|
|
47
|
+
/** Reply chunk ceiling (default MAX_CHUNK). */
|
|
48
|
+
maxChunk?: number;
|
|
49
|
+
/**
|
|
50
|
+
* T4 hook (kept structurally narrow so parallel tickets stay out of the
|
|
51
|
+
* route table): when present and it claims a line, the pipeline hands the
|
|
52
|
+
* message over AFTER the trust gate and never creates a session or a
|
|
53
|
+
* prompt for it. The implementation lives in agent-commands.ts.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* T4 hook (kept structurally narrow so parallel tickets stay out of the
|
|
57
|
+
* route table): when present and it claims a line, the pipeline hands the
|
|
58
|
+
* message over AFTER the trust gate and never creates a session or a
|
|
59
|
+
* prompt for it. The implementation lives in agent-commands.ts.
|
|
60
|
+
*/
|
|
61
|
+
commandRouter?: {
|
|
62
|
+
isCommand(text: string): boolean;
|
|
63
|
+
handle(text: string, ctx: {
|
|
64
|
+
roomId: string;
|
|
65
|
+
sender: string;
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* T5 挂点(最小侵入):trusted 之后、getSession 之前先问 intercept ——
|
|
70
|
+
* 返回 true 表示消息被挂起的提问/审批当作答案吃下,本轮流水线到此为止。
|
|
71
|
+
*/
|
|
72
|
+
intercept?: (msg: IncomingMessage) => Promise<boolean>;
|
|
73
|
+
/**
|
|
74
|
+
* T12(US37)挂点:提权账本。装配层(手里有 ProjectMap 与持久层的地方)注入 ——
|
|
75
|
+
* - revoke:按账本把该用户在所有记过账的房间降回普通成员;
|
|
76
|
+
* - heal:启动对账(先降"已不可信"的,再给当前 trusted 补提)。
|
|
77
|
+
* 缺省 = 不做权限管理(逐条行为与 T12 之前完全一致)。
|
|
78
|
+
*/
|
|
79
|
+
power?: {
|
|
80
|
+
revoke: (userId: string) => Promise<{
|
|
81
|
+
demoted: number;
|
|
82
|
+
failures: number;
|
|
83
|
+
}>;
|
|
84
|
+
heal: () => Promise<{
|
|
85
|
+
elevated: number;
|
|
86
|
+
demoted: number;
|
|
87
|
+
failures: number;
|
|
88
|
+
}>;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* T12(US38)挂点:space 归集与头像自愈。装配层在**开启空间模式**时注入;
|
|
92
|
+
* 缺省 = 不建 space、不挂头像(逐条行为与 T12 之前一致)。
|
|
93
|
+
*/
|
|
94
|
+
space?: {
|
|
95
|
+
heal: () => Promise<{
|
|
96
|
+
spaceCreated: boolean;
|
|
97
|
+
avatarsSet: number;
|
|
98
|
+
childrenAdded: number;
|
|
99
|
+
failures: number;
|
|
100
|
+
}>;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* T7 挂点:按 DSH session id 取流式源(配置 streamReplies 开启时装配)。
|
|
104
|
+
* 缺省或返回 undefined = 回合完成一次性回帖(T1 行为,逐字节不变)。
|
|
105
|
+
*/
|
|
106
|
+
turnStream?: (sessionId: string) => TurnStreamSource | undefined;
|
|
107
|
+
/** Injectable clock for the streaming edit throttle (tests); default Date.now. */
|
|
108
|
+
now?: () => number;
|
|
109
|
+
/** Streaming edit throttle window (ms); default 1500. */
|
|
110
|
+
streamThrottleMs?: number;
|
|
111
|
+
/**
|
|
112
|
+
* 会话被别的写句柄占用(DSH GUI 开着同一房间会话)时的重试节奏:
|
|
113
|
+
* 每次等 `sessionBusyRetryMs`(默认 SESSION_BUSY_RETRY_MS),最多
|
|
114
|
+
* `sessionBusyRetries` 次(默认 SESSION_BUSY_RETRIES)。测试注入 0 秒。
|
|
115
|
+
*/
|
|
116
|
+
sessionBusyRetryMs?: number;
|
|
117
|
+
sessionBusyRetries?: number;
|
|
118
|
+
/** 可注入睡眠(测试用);缺省 = setTimeout。 */
|
|
119
|
+
sleep?: (ms: number) => Promise<void>;
|
|
120
|
+
/** T6 附件策略:单附件上限 MB(US28,默认 10)。 */
|
|
121
|
+
attachments?: {
|
|
122
|
+
maxMb?: number;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* T9 多项目:房间 → cwd 解析。缺省恒返回 workdir(单项目模式逐条不变);
|
|
126
|
+
* 装配层在 multiProject 时组合 ProjectMap 得到每项目独立 cwd。
|
|
127
|
+
*/
|
|
128
|
+
resolveCwd?: (roomId: string) => string | Promise<string>;
|
|
129
|
+
/**
|
|
130
|
+
* T9 多项目挂点(可选)。trusted 之后、命令表之前依次:收养管理房 →
|
|
131
|
+
* /pmctl 消费判定。缺省 = 单项目,不改变现有行为。
|
|
132
|
+
*/
|
|
133
|
+
multiProject?: {
|
|
134
|
+
/** 管理房间收养(非消费):首条 trusted 私聊触发,幂等。 */
|
|
135
|
+
adoptRoom(msg: IncomingMessage): Promise<void>;
|
|
136
|
+
/** 该房是否已定管理房。 */
|
|
137
|
+
isManagementRoom(roomId: string): boolean;
|
|
138
|
+
/** /pmctl 家族;返回 true = 已消费(回执或执行过),pipeline 结束本轮。 */
|
|
139
|
+
handlePmctl(text: string, call: {
|
|
140
|
+
roomId: string;
|
|
141
|
+
sender: string;
|
|
142
|
+
isManagementRoom: boolean;
|
|
143
|
+
}, reply: (t: string) => Promise<void>): Promise<boolean>;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export interface Bridge {
|
|
147
|
+
start(): Promise<void>;
|
|
148
|
+
stop(): Promise<void>;
|
|
149
|
+
}
|
|
150
|
+
export declare function createBridge(deps: BridgeDeps): Bridge;
|
|
151
|
+
//# sourceMappingURL=pipeline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/bridge/pipeline.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { isSessionBusyError } from "../ports/backend.js";
|
|
2
|
+
import { splitMessage } from "../formatting.js";
|
|
3
|
+
import { createTurnResponder } from "./reply.js";
|
|
4
|
+
import { handleTurnCommand, parseTurnCommand, TURN_COMMANDS } from "./turn-commands.js";
|
|
5
|
+
import { executeAdminCommand, parseAdminCommand } from "../auth/admin-commands.js";
|
|
6
|
+
import { createCommandRegistry } from "./commands.js";
|
|
7
|
+
import { createGroupGate, isGroupMessage } from "./group-gate.js";
|
|
8
|
+
import { createQuoteCache, extractUsername, toExcerpt } from "./quote-cache.js";
|
|
9
|
+
import { MediaTooLargeError } from "../ports/attachments.js";
|
|
10
|
+
import { attachmentKindFor, attachmentTooLargeReply, createAttachmentsLedger, downloadFailureReply, duplicateAttachmentReply, unsupportedTypeReply, } from "./attachments-ledger.js";
|
|
11
|
+
/** Matrix text message hard limit; split replies beneath it. */
|
|
12
|
+
export const MAX_CHUNK = 4000;
|
|
13
|
+
/** 会话被占用时的第一次通知(单点文案,便于以后统一改口吻/多语言)。 */
|
|
14
|
+
export const SESSION_BUSY_NOTICE = '⏳ 这个房间的会话正被 DSH GUI 占用(DSH 同一会话只允许一个写入口),消息还没送进 agent。'
|
|
15
|
+
+ '我去重试,你在 GUI 里关掉这个会话的标签页后会自动继续。';
|
|
16
|
+
/**
|
|
17
|
+
* 重试预算耗尽后的可操作说明(US42:失败不许静默)。
|
|
18
|
+
*
|
|
19
|
+
* `waitedMs` 是桥真正花掉的**重试窗口**(retries × 每次间隔),不是墙上时钟:
|
|
20
|
+
* 实测里通知本身还要走一次 Matrix 发送(约 4 秒),那不属于重试。宁可如实说
|
|
21
|
+
* 重试了多久,也不要印一个凑出来的数字。
|
|
22
|
+
*/
|
|
23
|
+
export function sessionBusyGiveUpReply(waitedMs) {
|
|
24
|
+
const secs = Math.round(waitedMs / 1000);
|
|
25
|
+
const lead = secs >= 1 ? `⚠️ 重试了 ${secs} 秒,` : '⚠️ ';
|
|
26
|
+
return `${lead}这个房间的会话仍被别人占用,这条消息没能进 agent。\n`
|
|
27
|
+
+ '请在 DSH GUI 里关掉该房间会话的标签页(或退出 GUI),然后重新发一次。';
|
|
28
|
+
}
|
|
29
|
+
/** 默认重试节奏:每 5 秒一次,共 12 次 = 60 秒重试窗口。 */
|
|
30
|
+
export const SESSION_BUSY_RETRY_MS = 5_000;
|
|
31
|
+
export const SESSION_BUSY_RETRIES = 12;
|
|
32
|
+
export function createBridge(deps) {
|
|
33
|
+
const { transport, backend, logger, auth } = deps;
|
|
34
|
+
const trusted = new Set(deps.trustedUsers);
|
|
35
|
+
const maxChunk = deps.maxChunk && deps.maxChunk > 0 ? deps.maxChunk : MAX_CHUNK;
|
|
36
|
+
let running = true;
|
|
37
|
+
let unsubscribe;
|
|
38
|
+
let unsubscribeJoin;
|
|
39
|
+
// T6: pending-attachment ledger, keyed room+sender (US25). Purely in-memory
|
|
40
|
+
// BY DESIGN — a bridge restart clears it (every save echoed a receipt).
|
|
41
|
+
const maxAttachBytes = Math.max(1, deps.attachments?.maxMb ?? 10) * 1024 * 1024;
|
|
42
|
+
const ledger = createAttachmentsLedger({ maxBytes: maxAttachBytes });
|
|
43
|
+
// --- T2 auth stage -------------------------------------------------------
|
|
44
|
+
// Runtime trust (challenge winners) unions with the static config list.
|
|
45
|
+
const isTrusted = (userId) => trusted.has(userId) || auth.store.get().trustedUsers.includes(userId);
|
|
46
|
+
// Admin = persisted adminUserId, else the config list's first entry.
|
|
47
|
+
const adminId = () => auth.store.get().adminUserId ?? [...trusted][0];
|
|
48
|
+
const commands = createCommandRegistry();
|
|
49
|
+
const registerAdmin = (name) => {
|
|
50
|
+
commands.register(name, async ({ msg, respond }) => {
|
|
51
|
+
const parsed = parseAdminCommand(msg.text);
|
|
52
|
+
if (!parsed || parsed.name !== name)
|
|
53
|
+
return;
|
|
54
|
+
const reply = executeAdminCommand(parsed, {
|
|
55
|
+
store: auth.store,
|
|
56
|
+
sender: msg.sender,
|
|
57
|
+
roomId: msg.roomId,
|
|
58
|
+
isTrusted: (u) => isTrusted(u),
|
|
59
|
+
isAdmin: (u) => adminId() !== undefined && u === adminId(),
|
|
60
|
+
notify: (m) => logger.info(m),
|
|
61
|
+
});
|
|
62
|
+
await respond(reply);
|
|
63
|
+
// A revoked user must re-enter the challenge flow cleanly: drop any
|
|
64
|
+
// stale challenge state kept for them.
|
|
65
|
+
if (name === 'revoke' && parsed.name === 'revoke' && parsed.target) {
|
|
66
|
+
auth.challenge.reset(parsed.target);
|
|
67
|
+
// T12(US37):把权限也收回来 —— 只降账本里我们提过的房间,绝不碰管理员
|
|
68
|
+
// 在别处手工给的权限。失败只提示,不回滚信任变更(信任该撤就撤)。
|
|
69
|
+
const power = deps.power;
|
|
70
|
+
if (power) {
|
|
71
|
+
try {
|
|
72
|
+
const stripped = await power.revoke(parsed.target);
|
|
73
|
+
if (stripped.demoted > 0 || stripped.failures > 0) {
|
|
74
|
+
await transport.notify(msg.roomId, `🔻 已在 ${stripped.demoted} 个房间降回普通成员` +
|
|
75
|
+
(stripped.failures > 0 ? `(${stripped.failures} 处失败,下次启动重试)` : ''));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logger.warn('revoke 降级失败:', error instanceof Error ? error.message : error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
registerAdmin('trusted');
|
|
86
|
+
registerAdmin('revoke');
|
|
87
|
+
// T8: group toggle commands. /enable is trusted-level, /disable and
|
|
88
|
+
// /channels admin-only — the permission ladder lives in admin-commands.
|
|
89
|
+
const registerGroup = (name) => {
|
|
90
|
+
commands.register(name, async ({ msg, respond }) => {
|
|
91
|
+
const parsed = parseAdminCommand(msg.text);
|
|
92
|
+
if (!parsed || parsed.name !== name)
|
|
93
|
+
return;
|
|
94
|
+
const reply = executeAdminCommand(parsed, {
|
|
95
|
+
store: auth.store,
|
|
96
|
+
sender: msg.sender,
|
|
97
|
+
roomId: msg.roomId,
|
|
98
|
+
isTrusted: (u) => isTrusted(u),
|
|
99
|
+
isAdmin: (u) => adminId() !== undefined && u === adminId(),
|
|
100
|
+
notify: (m) => logger.info(m),
|
|
101
|
+
});
|
|
102
|
+
await respond(reply);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
registerGroup('channels');
|
|
106
|
+
registerGroup('enable');
|
|
107
|
+
registerGroup('disable');
|
|
108
|
+
// T8: group room policy + reply-quote excerpt ring (memory-bounded).
|
|
109
|
+
const groupGate = createGroupGate({ store: auth.store });
|
|
110
|
+
const quoteCache = createQuoteCache();
|
|
111
|
+
/** US33: prepend the cached quote of a reply-to target to the model text. */
|
|
112
|
+
function promptTextFor(msg) {
|
|
113
|
+
if (!msg.replyTo)
|
|
114
|
+
return msg.text;
|
|
115
|
+
const quote = quoteCache.lookup(msg.roomId, msg.replyTo.eventId);
|
|
116
|
+
if (!quote)
|
|
117
|
+
return msg.text; // cache miss: silent downgrade
|
|
118
|
+
return `「${quote.username}: ${quote.excerpt}」\n${msg.text}`;
|
|
119
|
+
}
|
|
120
|
+
/** Auth decisions for a stranger's DM (T2). Group senders go through the
|
|
121
|
+
* group gate in handleIncoming instead (T8). True = message consumed. */
|
|
122
|
+
async function handleUntrusted(msg) {
|
|
123
|
+
const code = auth.challenge.submitCode(msg.sender, msg.text);
|
|
124
|
+
const outcome = code.kind !== 'none' ? code : auth.challenge.gateDm(msg.sender);
|
|
125
|
+
switch (outcome.kind) {
|
|
126
|
+
case 'authenticated':
|
|
127
|
+
await transport.send(msg.roomId, '✅ 认证成功!现在可以正常对话了。');
|
|
128
|
+
break;
|
|
129
|
+
case 'expired':
|
|
130
|
+
await transport.send(msg.roomId, '⏱️ 验证码已过期,随便发条消息即可领取新码。');
|
|
131
|
+
break;
|
|
132
|
+
case 'wrong':
|
|
133
|
+
await transport.send(msg.roomId, `❌ 验证码错误,还剩 ${outcome.attemptsLeft} 次机会。`);
|
|
134
|
+
break;
|
|
135
|
+
case 'locked': {
|
|
136
|
+
const mins = Math.ceil(outcome.retryInMs / 60_000);
|
|
137
|
+
await transport.send(msg.roomId, `🚫 连续 3 次错误,锁定 ${mins} 分钟后再试。`);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case 'challenged':
|
|
141
|
+
await transport.send(msg.roomId, outcome.prompt);
|
|
142
|
+
break;
|
|
143
|
+
case 'pending':
|
|
144
|
+
await transport.notify(msg.roomId, '⏳ 挑战进行中:请把日志里的 6 位验证码发过来(2 分钟有效)。');
|
|
145
|
+
break;
|
|
146
|
+
case 'trusted':
|
|
147
|
+
break; // fall through to normal processing below
|
|
148
|
+
}
|
|
149
|
+
return outcome.kind !== 'trusted';
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* T6 media intake (US25-28): declared-size pre-check → download → real-size
|
|
153
|
+
* re-check (ledger.save) → receipt per item. Failures are receipted, never
|
|
154
|
+
* swallowed (US42); the message itself still does not wake the agent.
|
|
155
|
+
*/
|
|
156
|
+
async function handleMediaMessage(msg) {
|
|
157
|
+
for (const m of msg.media ?? []) {
|
|
158
|
+
// Cheap pre-check against the DECLARED size before spending a download
|
|
159
|
+
// (pi-courier AttachmentStore 同款;info.size 是提示,不是信任依据)。
|
|
160
|
+
if (m.size !== undefined && m.size > maxAttachBytes) {
|
|
161
|
+
await transport.send(msg.roomId, attachmentTooLargeReply(m.size, maxAttachBytes));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ledger.has(msg.roomId, msg.sender, m.mxc)) {
|
|
165
|
+
await transport.send(msg.roomId, duplicateAttachmentReply());
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let downloaded;
|
|
169
|
+
try {
|
|
170
|
+
downloaded = await transport.downloadMedia(m.mxc, maxAttachBytes);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const tooLarge = error instanceof MediaTooLargeError ? error : undefined;
|
|
174
|
+
await transport.send(msg.roomId, tooLarge
|
|
175
|
+
? attachmentTooLargeReply(tooLarge.bytes, tooLarge.maxBytes)
|
|
176
|
+
: downloadFailureReply(error instanceof Error ? error.message : String(error)));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const item = {
|
|
180
|
+
mxc: m.mxc,
|
|
181
|
+
kind: attachmentKindFor(m.msgtype),
|
|
182
|
+
bytes: downloaded.bytes,
|
|
183
|
+
mimeType: downloaded.mimeType,
|
|
184
|
+
filename: downloaded.filename ?? m.filename ?? m.body,
|
|
185
|
+
};
|
|
186
|
+
// Real-byte cap re-check + dedup admission happen inside save(); the
|
|
187
|
+
// returned string is the room receipt (成功/拒绝/去重 三态文案)。
|
|
188
|
+
await transport.send(msg.roomId, ledger.save(msg.roomId, msg.sender, item));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Per-room FIFO: each room's turns are processed one at a time, so two
|
|
192
|
+
// fast messages can never interleave their whenIdle/reply cycles.
|
|
193
|
+
const roomQueues = new Map();
|
|
194
|
+
// Matrix clients can redeliver the same event after a resync; process each
|
|
195
|
+
// event id exactly once (bounded ring, same spirit as pi-courier's seen-set).
|
|
196
|
+
const seenEvents = new Set();
|
|
197
|
+
const seenOrder = [];
|
|
198
|
+
function noteSeen(eventId) {
|
|
199
|
+
if (!eventId)
|
|
200
|
+
return true; // nothing to dedupe against - let it through
|
|
201
|
+
if (seenEvents.has(eventId))
|
|
202
|
+
return false;
|
|
203
|
+
seenEvents.add(eventId);
|
|
204
|
+
seenOrder.push(eventId);
|
|
205
|
+
if (seenOrder.length > 500) {
|
|
206
|
+
const drop = seenOrder.splice(0, seenOrder.length - 500);
|
|
207
|
+
for (const id of drop)
|
|
208
|
+
seenEvents.delete(id);
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* T12 重试分支:只在 `getSession` 已经撞上"写句柄被别人占着"之后才进来。
|
|
214
|
+
*
|
|
215
|
+
* DSH 一个会话同一时刻只允许一个写句柄。DSH GUI(或另一个桥实例)打开着
|
|
216
|
+
* 该房间会话时,resume 会抛 SessionBusyError —— 旧行为是 pipeline error
|
|
217
|
+
* 日志 + 静默吞消息,房间里什么都看不到(死机重启后 GUI 恢复标签页就踩中)。
|
|
218
|
+
* 现在:第一次占用就通知房间,按间隔重试,预算耗尽给出可操作说明。
|
|
219
|
+
*
|
|
220
|
+
* 成功路径刻意**不**经过这里:多包一层 async 会多一个微任务,把回合启动
|
|
221
|
+
* 推迟一拍,足以改变 steer/queue 的既有交错(实测带偏 T6 的 steer 用例)。
|
|
222
|
+
* 调用点直接 await 后端那一个 promise,零额外调度。
|
|
223
|
+
*/
|
|
224
|
+
async function retryBusySession(roomId, cwd, cause) {
|
|
225
|
+
const retries = Math.max(0, deps.sessionBusyRetries ?? SESSION_BUSY_RETRIES);
|
|
226
|
+
const waitMs = Math.max(0, deps.sessionBusyRetryMs ?? SESSION_BUSY_RETRY_MS);
|
|
227
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
228
|
+
let noticed = false;
|
|
229
|
+
for (let attempt = 0;; attempt++) {
|
|
230
|
+
if (attempt >= retries) {
|
|
231
|
+
await transport.send(roomId, sessionBusyGiveUpReply(retries * waitMs));
|
|
232
|
+
throw cause; // 抛回原始错误:真会话 id 与报错链都留着,便于排查
|
|
233
|
+
}
|
|
234
|
+
if (!noticed) {
|
|
235
|
+
noticed = true;
|
|
236
|
+
await transport.notify(roomId, SESSION_BUSY_NOTICE);
|
|
237
|
+
}
|
|
238
|
+
logger.info(`room ${roomId} 会话被占用,第 ${attempt + 1}/${retries} 次重试(等 ${waitMs}ms)`);
|
|
239
|
+
await sleep(waitMs);
|
|
240
|
+
try {
|
|
241
|
+
return await backend.getSession(roomId, cwd);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// 不是占用(真坏了)就立刻交出去,绝不拿重试掩盖真相。
|
|
245
|
+
if (!isSessionBusyError(error))
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function handleIncoming(msg) {
|
|
251
|
+
if (!running)
|
|
252
|
+
return;
|
|
253
|
+
// T6: media / unsupported payloads carry no text but must NOT be silent
|
|
254
|
+
// (pi-courier 票3 "消灭静默吞消息"); pure-empty lines still drop.
|
|
255
|
+
// 媒体消息 caption 为空是常态:归一化为 "" 继续走(命令表对空文本不命中)。
|
|
256
|
+
const hasPayload = Boolean(msg.text?.trim()) || (msg.media?.length ?? 0) > 0 || Boolean(msg.unsupportedMsgtype);
|
|
257
|
+
if (!hasPayload)
|
|
258
|
+
return;
|
|
259
|
+
if (!msg.text)
|
|
260
|
+
msg = { ...msg, text: '' };
|
|
261
|
+
if (!noteSeen(msg.eventId))
|
|
262
|
+
return;
|
|
263
|
+
// T8/US33: every accepted message becomes quotable context (per room).
|
|
264
|
+
quoteCache.record(msg.roomId, msg.eventId, {
|
|
265
|
+
username: extractUsername(msg.sender),
|
|
266
|
+
excerpt: toExcerpt(msg.text),
|
|
267
|
+
});
|
|
268
|
+
// T8: group policy BEFORE everything else (US32). The gate also lets a
|
|
269
|
+
// trusted sender's /enable through while the room is still disabled.
|
|
270
|
+
const trustedNow = isTrusted(msg.sender);
|
|
271
|
+
if (isGroupMessage(msg)) {
|
|
272
|
+
const decision = groupGate.check(msg, trustedNow);
|
|
273
|
+
if (decision.action === 'silence')
|
|
274
|
+
return;
|
|
275
|
+
if (decision.action === 'enable-hint') {
|
|
276
|
+
await transport.notify(msg.roomId, decision.text);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (decision.action === 'reject-notice') {
|
|
280
|
+
await transport.notify(msg.roomId, `(dsh-matrix) 已忽略:${msg.sender} 不在 trustedUsers`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (!trustedNow) {
|
|
285
|
+
const consumed = await handleUntrusted(msg);
|
|
286
|
+
if (consumed)
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// T6 attachment stage (US25): media messages are downloaded, receipted
|
|
290
|
+
// and parked in the ledger HERE — they never wake the agent and never
|
|
291
|
+
// reach the command/prompt route. Their caption is not forwarded (the
|
|
292
|
+
// ledger waits for the sender's NEXT text message).
|
|
293
|
+
if (msg.media?.length) {
|
|
294
|
+
await handleMediaMessage(msg);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (msg.unsupportedMsgtype) {
|
|
298
|
+
await transport.send(msg.roomId, unsupportedTypeReply(msg.unsupportedMsgtype));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// T9: trusted 之后先做管理房间收养(非消费),再判 /pmctl(消费即结束本轮)。
|
|
302
|
+
const mp = deps.multiProject;
|
|
303
|
+
if (mp) {
|
|
304
|
+
await mp.adoptRoom(msg);
|
|
305
|
+
const consumed = await mp.handlePmctl(msg.text, { roomId: msg.roomId, sender: msg.sender, isManagementRoom: mp.isManagementRoom(msg.roomId) }, (text) => transport.send(msg.roomId, text).then(() => undefined));
|
|
306
|
+
if (consumed)
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Command table (T2: admin commands) BEFORE the prompt path.
|
|
310
|
+
const invocation = commands.match(msg.text);
|
|
311
|
+
if (invocation) {
|
|
312
|
+
await invocation.handler({
|
|
313
|
+
msg,
|
|
314
|
+
respond: (text) => transport.send(msg.roomId, text).then(() => undefined),
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// T4: slash lines belong to the command surface, never to the model —
|
|
319
|
+
// EXCEPT names owned by the T3 turn table (a hijacked /stop would settle
|
|
320
|
+
// as "unknown command" there). parseTurnCommand parses ANY slash, so the
|
|
321
|
+
// ownership check goes through the known-name set below.
|
|
322
|
+
const turnParsed = parseTurnCommand(msg.text);
|
|
323
|
+
const router = deps.commandRouter;
|
|
324
|
+
if (router?.isCommand(msg.text) && !(turnParsed && TURN_COMMANDS.has(turnParsed.name))) {
|
|
325
|
+
await router.handle(msg.text, { roomId: msg.roomId, sender: msg.sender });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// T5 挂点:挂起提问/审批的答案在此被吃下,不进入会话路径
|
|
329
|
+
if (await deps.intercept?.(msg))
|
|
330
|
+
return;
|
|
331
|
+
const cwd = deps.resolveCwd ? await deps.resolveCwd(msg.roomId) : deps.workdir;
|
|
332
|
+
// 成功路径与改动前逐微任务一致(直接 await 后端那一个 promise);
|
|
333
|
+
// 只有撞上"写句柄被占用"才转进重试分支。
|
|
334
|
+
let session;
|
|
335
|
+
try {
|
|
336
|
+
session = await backend.getSession(msg.roomId, cwd);
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (!isSessionBusyError(error))
|
|
340
|
+
throw error;
|
|
341
|
+
session = await retryBusySession(msg.roomId, cwd, error);
|
|
342
|
+
}
|
|
343
|
+
// T3: turn-control commands run AFTER the trust gate and BEFORE the
|
|
344
|
+
// prompt/steer split. A running slash command must never be steered
|
|
345
|
+
// into the model; unknown slash names fall through (reply === undefined).
|
|
346
|
+
if (turnParsed && TURN_COMMANDS.has(turnParsed.name)) {
|
|
347
|
+
const commandReply = await handleTurnCommand(turnParsed.name, turnParsed.args, { session });
|
|
348
|
+
if (commandReply !== undefined) {
|
|
349
|
+
for (const chunk of splitMessage(commandReply, maxChunk)) {
|
|
350
|
+
await transport.send(msg.roomId, chunk);
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (session.isRunning()) {
|
|
356
|
+
// TUI Enter semantics: inject into the live turn.
|
|
357
|
+
session.steer(promptTextFor(msg));
|
|
358
|
+
await transport.notify(msg.roomId, '(dsh-matrix) 已 steer 进当前回合');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// T7: the reply segment lands via the responder. Without a stream source
|
|
362
|
+
// this is byte-for-byte the T1 behavior (no placeholder; finalize sends the
|
|
363
|
+
// splitMessage chunks exactly like the old loop did).
|
|
364
|
+
const stream = deps.turnStream?.(session.id);
|
|
365
|
+
const responder = createTurnResponder({
|
|
366
|
+
transport,
|
|
367
|
+
roomId: msg.roomId,
|
|
368
|
+
maxChunk,
|
|
369
|
+
...(stream ? { stream, sessionId: session.id } : {}),
|
|
370
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
371
|
+
...(deps.streamThrottleMs !== undefined ? { throttleMs: deps.streamThrottleMs } : {}),
|
|
372
|
+
});
|
|
373
|
+
await responder.start();
|
|
374
|
+
try {
|
|
375
|
+
// T6 consumption — the ledger's ONLY consumer (US25: 下一条文本才带上).
|
|
376
|
+
// clear 在 prompt() 未抛错之后:发送失败时附件留在账本等重试(pi-courier 票1)。
|
|
377
|
+
const carried = ledger.peek(msg.roomId, msg.sender);
|
|
378
|
+
if (carried.length) {
|
|
379
|
+
session.prompt(promptTextFor(msg), carried);
|
|
380
|
+
ledger.clear(msg.roomId, msg.sender);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
session.prompt(promptTextFor(msg)); // T8: quoted excerpt joins the prompt
|
|
384
|
+
}
|
|
385
|
+
await session.whenIdle();
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
responder.dispose(); // a dead turn must not keep editing the placeholder
|
|
389
|
+
// US42「失败不许静默」:回合失败必须让房间知道。这里原先只 rethrow,外层
|
|
390
|
+
// 仅 logger.error —— 用户那边是占位消息消失后一片安静,完全不知道出了什么事。
|
|
391
|
+
// 贴一行失败原因后再抛出:运维侧仍拿到完整堆栈,房间里也知道为什么没回答。
|
|
392
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
393
|
+
try {
|
|
394
|
+
await transport.send(msg.roomId, `❌ 回合失败:${detail || '未知错误'}`);
|
|
395
|
+
}
|
|
396
|
+
catch (sendError) {
|
|
397
|
+
logger.warn('失败提示回帖失败:', sendError instanceof Error ? sendError.message : sendError);
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
// US42 的另一半:DSH 的回合可以以 `turn/end{reason.kind:'error'}` 收尾而
|
|
402
|
+
// 不抛错(prompt/whenIdle 照常 resolve),上面那个 catch 兜不住。这里在回合
|
|
403
|
+
// 结束后主动查一次:失败就撤掉占位并如实报原因,绝不静默。
|
|
404
|
+
const turnFailure = session.lastTurnFailure();
|
|
405
|
+
if (turnFailure) {
|
|
406
|
+
responder.dispose(); // 失败的回合不落定稿
|
|
407
|
+
try {
|
|
408
|
+
await transport.send(msg.roomId, `❌ 回合失败:${turnFailure}`);
|
|
409
|
+
}
|
|
410
|
+
catch (sendError) {
|
|
411
|
+
logger.warn('失败提示回帖失败:', sendError instanceof Error ? sendError.message : sendError);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await responder.finalize(session.lastReply());
|
|
416
|
+
}
|
|
417
|
+
function enqueue(msg) {
|
|
418
|
+
logger.debug(`incoming: room=${msg.roomId} sender=${msg.sender} text="${msg.text.slice(0, 60)}"`);
|
|
419
|
+
// A room whose session is mid-turn needs NO queue: steering is an atomic
|
|
420
|
+
// inbox injection, and waiting behind the queue would make steer dead code
|
|
421
|
+
// (the turn is always finished by then). Non-turn paths inside
|
|
422
|
+
// handleIncoming (trust notice, dedupe) are side-effect-free per message.
|
|
423
|
+
const live = backend.peekSession(msg.roomId);
|
|
424
|
+
if (live?.isRunning()) {
|
|
425
|
+
void Promise.resolve().then(() => handleIncoming(msg)).catch((error) => logger.error('pipeline error:', error instanceof Error ? error.stack : error));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const prev = roomQueues.get(msg.roomId) ?? Promise.resolve();
|
|
429
|
+
const next = prev
|
|
430
|
+
.then(() => handleIncoming(msg))
|
|
431
|
+
.catch((error) => logger.error('pipeline error:', error instanceof Error ? error.stack : error));
|
|
432
|
+
roomQueues.set(msg.roomId, next);
|
|
433
|
+
next.then(() => {
|
|
434
|
+
if (roomQueues.get(msg.roomId) === next)
|
|
435
|
+
roomQueues.delete(msg.roomId);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
async start() {
|
|
440
|
+
unsubscribe = transport.onIncoming((msg) => { enqueue(msg); });
|
|
441
|
+
// T8: one-time join hint for freshly-invited group rooms (US32).
|
|
442
|
+
unsubscribeJoin = transport.onRoomJoin?.((roomId, isGroup) => {
|
|
443
|
+
if (!running || !isGroup)
|
|
444
|
+
return;
|
|
445
|
+
const hint = groupGate.joinHint(roomId);
|
|
446
|
+
if (hint) {
|
|
447
|
+
void transport.notify(roomId, hint).catch((error) => logger.warn('join hint:', error));
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
await transport.start(logger);
|
|
451
|
+
// T12(US37):启动对账 —— 先把已经不在 trusted 里的人降掉并销账,再给当前
|
|
452
|
+
// trusted 补提。幂等,失败只记日志(权限是附加能力,不该拦住启动)。
|
|
453
|
+
if (deps.power) {
|
|
454
|
+
try {
|
|
455
|
+
const healed = await deps.power.heal();
|
|
456
|
+
if (healed.elevated > 0 || healed.demoted > 0 || healed.failures > 0) {
|
|
457
|
+
logger.info(`T12 权限对账:补提 ${healed.elevated}、降级 ${healed.demoted}、失败 ${healed.failures}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
logger.warn('启动权限对账失败:', error instanceof Error ? error.message : error);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// T12(US38):space 归集 + 头像自愈(同样幂等、失败只记日志)。
|
|
465
|
+
if (deps.space) {
|
|
466
|
+
try {
|
|
467
|
+
const healed = await deps.space.heal();
|
|
468
|
+
if (healed.spaceCreated || healed.avatarsSet > 0 || healed.childrenAdded > 0 || healed.failures > 0) {
|
|
469
|
+
logger.info(`T12 space 对账:建 space ${healed.spaceCreated ? '是' : '否'}、补头像 ${healed.avatarsSet}、挂房间 ${healed.childrenAdded}、失败 ${healed.failures}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
logger.warn('启动 space 对账失败:', error instanceof Error ? error.message : error);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
logger.info(`🚀 dsh-matrix ready. trusted users: ${[...trusted].join(', ') || '(none!)'} (runtime list: ${auth.store.get().trustedUsers.length}, admin: ${adminId() ?? 'none'})`);
|
|
477
|
+
},
|
|
478
|
+
async stop() {
|
|
479
|
+
running = false;
|
|
480
|
+
// T13:把关闭过程写出来 —— 这段日志是判断"收到 SIGTERM 后插件到底有没有被
|
|
481
|
+
// dispose"的唯一依据(crypto 存储要在这一步被干净关闭,SQLite 才会把 WAL
|
|
482
|
+
// 合并回主库)。此前停止后主库仍 4KB、WAL 3.9MB,怀疑根本没走到这里。
|
|
483
|
+
logger.info('桥正在关闭:释放 Matrix 连接与 crypto 存储…');
|
|
484
|
+
unsubscribe?.();
|
|
485
|
+
unsubscribeJoin?.();
|
|
486
|
+
try {
|
|
487
|
+
await transport.stop();
|
|
488
|
+
}
|
|
489
|
+
catch (e) {
|
|
490
|
+
logger.warn('transport stop:', e);
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
await backend.dispose();
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
logger.warn('backend dispose:', e);
|
|
497
|
+
}
|
|
498
|
+
logger.info('桥已关闭');
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
//# sourceMappingURL=pipeline.js.map
|