mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
refactor: centralize channel ingress access
This commit is contained in:
@@ -25,7 +25,11 @@ import { setBridgeLogger } from "./logger.js";
|
||||
import { toGatewayAccount } from "./narrowing.js";
|
||||
import { resolveQQBotPluginVersion } from "./plugin-version.js";
|
||||
import { getQQBotRuntime, getQQBotRuntimeForEngine } from "./runtime.js";
|
||||
import { createSdkHistoryAdapter, createSdkMentionGateAdapter } from "./sdk-adapter.js";
|
||||
import {
|
||||
createSdkAccessAdapter,
|
||||
createSdkHistoryAdapter,
|
||||
createSdkMentionGateAdapter,
|
||||
} from "./sdk-adapter.js";
|
||||
|
||||
// ---- One-time startup initialization (module-level) ----
|
||||
|
||||
@@ -75,6 +79,7 @@ function createEngineAdapters(_runtime: GatewayPluginRuntime): EngineAdapters {
|
||||
return {
|
||||
history: createSdkHistoryAdapter(),
|
||||
mentionGate: createSdkMentionGateAdapter(),
|
||||
access: createSdkAccessAdapter(),
|
||||
audioConvert: {
|
||||
convertSilkToWav: _audioModule.convertSilkToWav,
|
||||
isVoiceAttachment: _audioModule.isVoiceAttachment,
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
/**
|
||||
* SDK adapter — binds engine port interfaces to the framework's shared
|
||||
* SDK implementations.
|
||||
*
|
||||
* This file lives in bridge/ (not engine/) because it imports from
|
||||
* `openclaw/plugin-sdk/*`. The engine layer stays zero-SDK-dependency;
|
||||
* only the bridge layer couples to the framework.
|
||||
*/
|
||||
|
||||
import {
|
||||
implicitMentionKindWhen,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-mention-gating";
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-mention-gating";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry as SdkHistoryEntry,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
import { resolveQQBotEffectivePolicies } from "../engine/access/resolve-policy.js";
|
||||
import { normalizeQQBotAllowFrom, normalizeQQBotSenderId } from "../engine/access/sender-match.js";
|
||||
import type { HistoryPort, HistoryEntryLike } from "../engine/adapter/history.port.js";
|
||||
import type {
|
||||
MentionGatePort,
|
||||
MentionGateDecision,
|
||||
MentionFacts,
|
||||
MentionPolicy,
|
||||
} from "../engine/adapter/mention-gate.port.js";
|
||||
import type { AccessPort } from "../engine/adapter/index.js";
|
||||
import type { MentionGatePort } from "../engine/adapter/mention-gate.port.js";
|
||||
|
||||
// ============ History Adapter ============
|
||||
const qqbotIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "sender-id",
|
||||
normalize: normalizeQQBotSenderId,
|
||||
isWildcardEntry: (entry) => normalizeQQBotSenderId(entry) === "*",
|
||||
});
|
||||
|
||||
// Helper: cast engine Map to SDK Map. TypeScript Map is invariant on its
|
||||
// value type, but the shapes are structurally identical (HistoryEntryLike
|
||||
// ⊇ SdkHistoryEntry). The `as unknown as` double-cast is safe here.
|
||||
function asSdkMap<T>(map: Map<string, T[]>): Map<string, SdkHistoryEntry[]> {
|
||||
return map as unknown as Map<string, SdkHistoryEntry[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* History adapter backed by SDK `reply-history`.
|
||||
*
|
||||
* Delegates record/build/clear to the SDK's shared implementation so
|
||||
* the engine benefits from SDK improvements (e.g. future visibility
|
||||
* filtering) without code duplication.
|
||||
*/
|
||||
export function createSdkHistoryAdapter(): HistoryPort {
|
||||
return {
|
||||
recordPendingHistoryEntry<T extends HistoryEntryLike>(params: {
|
||||
@@ -48,7 +33,7 @@ export function createSdkHistoryAdapter(): HistoryPort {
|
||||
historyKey: string;
|
||||
entry?: T | null;
|
||||
limit: number;
|
||||
}): T[] {
|
||||
}) {
|
||||
return recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: asSdkMap(params.historyMap),
|
||||
historyKey: params.historyKey,
|
||||
@@ -57,14 +42,7 @@ export function createSdkHistoryAdapter(): HistoryPort {
|
||||
}) as T[];
|
||||
},
|
||||
|
||||
buildPendingHistoryContext(params: {
|
||||
historyMap: Map<string, HistoryEntryLike[]>;
|
||||
historyKey: string;
|
||||
limit: number;
|
||||
currentMessage: string;
|
||||
formatEntry: (entry: HistoryEntryLike) => string;
|
||||
lineBreak?: string;
|
||||
}): string {
|
||||
buildPendingHistoryContext(params) {
|
||||
return buildPendingHistoryContextFromMap({
|
||||
historyMap: asSdkMap(params.historyMap),
|
||||
historyKey: params.historyKey,
|
||||
@@ -75,11 +53,7 @@ export function createSdkHistoryAdapter(): HistoryPort {
|
||||
});
|
||||
},
|
||||
|
||||
clearPendingHistory(params: {
|
||||
historyMap: Map<string, HistoryEntryLike[]>;
|
||||
historyKey: string;
|
||||
limit: number;
|
||||
}): void {
|
||||
clearPendingHistory(params) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: asSdkMap(params.historyMap),
|
||||
historyKey: params.historyKey,
|
||||
@@ -89,43 +63,105 @@ export function createSdkHistoryAdapter(): HistoryPort {
|
||||
};
|
||||
}
|
||||
|
||||
// ============ MentionGate Adapter ============
|
||||
|
||||
/**
|
||||
* MentionGate adapter backed by SDK `channel-mention-gating`.
|
||||
*
|
||||
* Maps the engine's mention facts/policy to the SDK's
|
||||
* `resolveInboundMentionDecision` call, normalizing the implicit
|
||||
* mention boolean into the SDK's typed `ImplicitMentionKind[]`.
|
||||
*/
|
||||
export function createSdkMentionGateAdapter(): MentionGatePort {
|
||||
return {
|
||||
resolveInboundMentionDecision(params: {
|
||||
facts: MentionFacts;
|
||||
policy: MentionPolicy;
|
||||
}): MentionGateDecision {
|
||||
const result = resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: params.facts.canDetectMention,
|
||||
wasMentioned: params.facts.wasMentioned,
|
||||
hasAnyMention: params.facts.hasAnyMention,
|
||||
implicitMentionKinds:
|
||||
params.facts.implicitMentionKinds ?? implicitMentionKindWhen("reply_to_bot", false),
|
||||
},
|
||||
policy: {
|
||||
isGroup: params.policy.isGroup,
|
||||
requireMention: params.policy.requireMention,
|
||||
allowTextCommands: params.policy.allowTextCommands,
|
||||
hasControlCommand: params.policy.hasControlCommand,
|
||||
commandAuthorized: params.policy.commandAuthorized,
|
||||
},
|
||||
});
|
||||
return {
|
||||
effectiveWasMentioned: result.effectiveWasMentioned,
|
||||
shouldSkip: result.shouldSkip,
|
||||
shouldBypassMention: result.shouldBypassMention,
|
||||
implicitMention: result.implicitMention,
|
||||
};
|
||||
resolveInboundMentionDecision(params) {
|
||||
return resolveInboundMentionDecision(params);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSdkAccessAdapter(): AccessPort {
|
||||
return {
|
||||
async resolveInboundAccess(input) {
|
||||
const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input);
|
||||
const rawGroupAllowFrom =
|
||||
input.groupAllowFrom && input.groupAllowFrom.length > 0
|
||||
? input.groupAllowFrom
|
||||
: (input.allowFrom ?? []);
|
||||
const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom);
|
||||
const dmAllowFromForIngress =
|
||||
dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : (input.allowFrom ?? []);
|
||||
|
||||
const commandOwnerAllowFrom = input.isGroup
|
||||
? []
|
||||
: input.allowFrom && input.allowFrom.length > 0
|
||||
? input.allowFrom
|
||||
: ["*"];
|
||||
const resolved = await createChannelIngressResolver({
|
||||
channelId: "qqbot",
|
||||
accountId: input.accountId,
|
||||
identity: qqbotIngressIdentity,
|
||||
cfg: input.cfg as OpenClawConfig,
|
||||
}).message({
|
||||
subject: { stableId: input.senderId },
|
||||
conversation: {
|
||||
kind: input.isGroup ? "group" : "direct",
|
||||
id: input.conversationId,
|
||||
},
|
||||
event: {
|
||||
mayPair: false,
|
||||
},
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
},
|
||||
allowFrom: dmAllowFromForIngress,
|
||||
groupAllowFrom: rawGroupAllowFrom,
|
||||
command: {
|
||||
commandOwnerAllowFrom,
|
||||
},
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
async resolveSlashCommandAuthorization(input) {
|
||||
return await resolveQQBotSlashCommandAuthorized(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveQQBotSlashCommandAuthorized(params: {
|
||||
cfg: unknown;
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
allowFrom?: Array<string | number> | null;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
commandsAllowFrom?: Array<string | number> | null;
|
||||
}): Promise<boolean> {
|
||||
const rawAllowFrom =
|
||||
params.commandsAllowFrom ??
|
||||
(params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
|
||||
? params.groupAllowFrom
|
||||
: params.allowFrom);
|
||||
const explicitAllowFrom = normalizeQQBotAllowFrom(rawAllowFrom).filter((entry) => entry !== "*");
|
||||
if (explicitAllowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const resolved = await createChannelIngressResolver({
|
||||
channelId: "qqbot",
|
||||
accountId: params.accountId,
|
||||
identity: qqbotIngressIdentity,
|
||||
cfg: params.cfg as OpenClawConfig,
|
||||
}).message({
|
||||
subject: { stableId: params.senderId },
|
||||
conversation: {
|
||||
kind: params.isGroup ? "group" : "direct",
|
||||
id: params.conversationId,
|
||||
},
|
||||
event: {
|
||||
kind: "slash-command",
|
||||
authMode: "none",
|
||||
mayPair: false,
|
||||
},
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "open",
|
||||
allowFrom: explicitAllowFrom,
|
||||
command: {
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
},
|
||||
});
|
||||
return resolved.commandAccess.authorized;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qqbotPlugin } from "./channel.js";
|
||||
import { createSdkAccessAdapter } from "./bridge/sdk-adapter.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// qqbot: prefix normalization for inbound commandAuthorized
|
||||
@@ -24,37 +24,60 @@ import { qqbotPlugin } from "./channel.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("qqbot: prefix normalization for inbound commandAuthorized", () => {
|
||||
const formatAllowFrom = qqbotPlugin.config.formatAllowFrom!;
|
||||
const access = createSdkAccessAdapter();
|
||||
|
||||
/** Mirrors the fixed gateway.ts inbound commandAuthorized computation. */
|
||||
function resolveInboundCommandAuthorized(rawAllowFrom: string[], senderId: string): boolean {
|
||||
const normalizedAllowFrom = formatAllowFrom({
|
||||
cfg: {} as never,
|
||||
accountId: null,
|
||||
async function resolveInboundCommandAuthorized(
|
||||
rawAllowFrom: string[],
|
||||
senderId: string,
|
||||
options: {
|
||||
isGroup?: boolean;
|
||||
groupAllowFrom?: string[];
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const result = await access.resolveInboundAccess({
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
conversationId: options.isGroup ? "group-openid" : senderId,
|
||||
isGroup: options.isGroup ?? false,
|
||||
senderId,
|
||||
allowFrom: rawAllowFrom,
|
||||
groupAllowFrom: options.groupAllowFrom,
|
||||
});
|
||||
const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase();
|
||||
const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*");
|
||||
return allowAll || normalizedAllowFrom.includes(normalizedSenderId);
|
||||
return result.commandAccess.authorized;
|
||||
}
|
||||
|
||||
it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", () => {
|
||||
expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).toBe(true);
|
||||
it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", async () => {
|
||||
await expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes when qqbot: prefix is mixed case", () => {
|
||||
expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).toBe(true);
|
||||
it("authorizes when qqbot: prefix is mixed case", async () => {
|
||||
await expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("denies a sender not in the qqbot:-prefixed allowFrom list", () => {
|
||||
expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).toBe(false);
|
||||
it("denies a sender not in the qqbot:-prefixed allowFrom list", async () => {
|
||||
await expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("authorizes any sender when allowFrom is empty (open)", () => {
|
||||
expect(resolveInboundCommandAuthorized([], "ANYONE")).toBe(true);
|
||||
it("authorizes any sender when allowFrom is empty (open)", async () => {
|
||||
await expect(resolveInboundCommandAuthorized([], "ANYONE")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes any sender when allowFrom contains wildcard *", () => {
|
||||
expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).toBe(true);
|
||||
it("authorizes any sender when allowFrom contains wildcard *", async () => {
|
||||
await expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("denies group command auth in an open group without explicit allowlists", async () => {
|
||||
await expect(resolveInboundCommandAuthorized([], "ANYONE", { isGroup: true })).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("authorizes group command auth for an explicit group allowlist sender", async () => {
|
||||
await expect(
|
||||
resolveInboundCommandAuthorized([], "GROUP_OWNER", {
|
||||
isGroup: true,
|
||||
groupAllowFrom: ["qqbot:GROUP_OWNER"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveQQBotAccess } from "./access-control.js";
|
||||
import { QQBOT_ACCESS_REASON } from "./types.js";
|
||||
|
||||
describe("resolveQQBotAccess", () => {
|
||||
describe("DM scenarios", () => {
|
||||
it("allows default-open DMs when allowFrom is omitted", () => {
|
||||
const result = resolveQQBotAccess({ isGroup: false, senderId: "USER1" });
|
||||
expect(result).toMatchObject({
|
||||
decision: "allow",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
|
||||
dmPolicy: "open",
|
||||
effectiveAllowFrom: ["*"],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows default-open DMs when allowFrom is explicitly empty", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER1",
|
||||
allowFrom: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
decision: "allow",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
|
||||
dmPolicy: "open",
|
||||
effectiveAllowFrom: ["*"],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows everyone with wildcard allowFrom", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER1",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_OPEN);
|
||||
});
|
||||
|
||||
it("allows sender matching the allowlist", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER1",
|
||||
allowFrom: ["USER1"],
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED);
|
||||
expect(result.dmPolicy).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("allows open mode when sender matches restrictive allowFrom", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER1",
|
||||
allowFrom: ["USER1"],
|
||||
dmPolicy: "open",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED);
|
||||
expect(result.reason).toBe("dmPolicy=open (allowlisted)");
|
||||
});
|
||||
|
||||
it("blocks sender not in allowlist", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER2",
|
||||
allowFrom: ["USER1"],
|
||||
});
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED);
|
||||
});
|
||||
|
||||
it("blocks DM when dmPolicy=disabled (even with wildcard)", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER1",
|
||||
allowFrom: ["*"],
|
||||
dmPolicy: "disabled",
|
||||
});
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_DISABLED);
|
||||
});
|
||||
|
||||
it("blocks DM with allowlist policy but empty allowlist", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER1",
|
||||
dmPolicy: "allowlist",
|
||||
});
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("normalizes qqbot: prefix and case when matching", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "qqbot:user1",
|
||||
allowFrom: ["QQBot:USER1"],
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("group scenarios", () => {
|
||||
it("inherits allowFrom for group access when no groupAllowFrom is set", () => {
|
||||
const allowed = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "USER1",
|
||||
allowFrom: ["USER1"],
|
||||
});
|
||||
expect(allowed.decision).toBe("allow");
|
||||
expect(allowed.groupPolicy).toBe("allowlist");
|
||||
|
||||
const blocked = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "USER2",
|
||||
allowFrom: ["USER1"],
|
||||
});
|
||||
expect(blocked.decision).toBe("block");
|
||||
expect(blocked.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED);
|
||||
});
|
||||
|
||||
it("uses groupAllowFrom when explicitly provided", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "USER2",
|
||||
allowFrom: ["USER1"],
|
||||
groupAllowFrom: ["USER2"],
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("blocks when groupPolicy=disabled", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "USER1",
|
||||
allowFrom: ["*"],
|
||||
groupPolicy: "disabled",
|
||||
});
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED);
|
||||
});
|
||||
|
||||
it("allows anyone when groupPolicy=open", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "RANDOM_USER",
|
||||
allowFrom: ["USER1"],
|
||||
groupPolicy: "open",
|
||||
});
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED);
|
||||
});
|
||||
|
||||
it("blocks when groupPolicy=allowlist but list is empty", () => {
|
||||
const result = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "USER1",
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backwards compatibility (legacy allowFrom-only configs)", () => {
|
||||
it("legacy allowFrom=['*'] stays fully open for both DM and group", () => {
|
||||
const dm = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "RANDOM",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
const group = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "RANDOM",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
expect(dm.decision).toBe("allow");
|
||||
expect(group.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("legacy allowFrom=['USER1'] locks down both DM and group to USER1", () => {
|
||||
const allowedDm = resolveQQBotAccess({
|
||||
isGroup: false,
|
||||
senderId: "USER1",
|
||||
allowFrom: ["USER1"],
|
||||
});
|
||||
const blockedGroup = resolveQQBotAccess({
|
||||
isGroup: true,
|
||||
senderId: "INTRUDER",
|
||||
allowFrom: ["USER1"],
|
||||
});
|
||||
expect(allowedDm.decision).toBe("allow");
|
||||
expect(blockedGroup.decision).toBe("block");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* QQBot inbound access decision.
|
||||
*
|
||||
* This module is the single place where the QQBot engine decides
|
||||
* whether an inbound message from a given sender is allowed to
|
||||
* proceed into the outbound pipeline. The implementation mirrors the
|
||||
* semantics of the framework-wide `resolveDmGroupAccessDecision`
|
||||
* (`src/security/dm-policy-shared.ts`) but is kept standalone so the
|
||||
* `engine/` layer does not pull in `openclaw/plugin-sdk/*` modules —
|
||||
* a hard constraint shared with the standalone `openclaw-qqbot` build.
|
||||
*
|
||||
* If in the future we lift the zero-dependency rule in the engine
|
||||
* layer, this file can be replaced by a thin adapter around the
|
||||
* framework API with identical semantics.
|
||||
*/
|
||||
|
||||
import { resolveQQBotEffectivePolicies, type EffectivePolicyInput } from "./resolve-policy.js";
|
||||
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js";
|
||||
import {
|
||||
QQBOT_ACCESS_REASON,
|
||||
type QQBotAccessResult,
|
||||
type QQBotDmPolicy,
|
||||
type QQBotGroupPolicy,
|
||||
} from "./types.js";
|
||||
|
||||
interface QQBotAccessInput extends EffectivePolicyInput {
|
||||
/** Whether the inbound originated in a group (or guild) chat. */
|
||||
isGroup: boolean;
|
||||
/** The raw inbound sender id as provided by the QQ event. */
|
||||
senderId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the inbound access policy.
|
||||
*
|
||||
* Semantics (aligned with `resolveDmGroupAccessDecision`):
|
||||
* - Group message:
|
||||
* - `groupPolicy=disabled` → block
|
||||
* - `groupPolicy=open` → allow
|
||||
* - `groupPolicy=allowlist`:
|
||||
* - empty effectiveGroupAllowFrom → block (empty_allowlist)
|
||||
* - sender not in list → block (not_allowlisted)
|
||||
* - otherwise → allow
|
||||
* - Direct message:
|
||||
* - `dmPolicy=disabled` → block
|
||||
* - `dmPolicy=open` → allow wildcard, legacy empty allowFrom, or matching allowFrom
|
||||
* - `dmPolicy=allowlist`:
|
||||
* - empty effectiveAllowFrom → block (empty_allowlist)
|
||||
* - sender not in list → block (not_allowlisted)
|
||||
* - otherwise → allow
|
||||
*
|
||||
* The function never throws; callers can rely on the returned
|
||||
* `decision`/`reasonCode` pair for branching.
|
||||
*/
|
||||
export function resolveQQBotAccess(input: QQBotAccessInput): QQBotAccessResult {
|
||||
const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input);
|
||||
|
||||
// Per framework convention: groupAllowFrom falls back to allowFrom
|
||||
// when not provided. We preserve that behaviour so a single
|
||||
// `allowFrom` entry locks down both DM and group.
|
||||
const rawGroupAllowFrom =
|
||||
input.groupAllowFrom && input.groupAllowFrom.length > 0
|
||||
? input.groupAllowFrom
|
||||
: (input.allowFrom ?? []);
|
||||
|
||||
const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom);
|
||||
const effectiveAllowFrom =
|
||||
dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : normalizedAllowFrom;
|
||||
const effectiveGroupAllowFrom = normalizeQQBotAllowFrom(rawGroupAllowFrom);
|
||||
|
||||
const isSenderAllowed = createQQBotSenderMatcher(input.senderId);
|
||||
|
||||
if (input.isGroup) {
|
||||
return evaluateGroupDecision({
|
||||
groupPolicy,
|
||||
dmPolicy,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
isSenderAllowed,
|
||||
});
|
||||
}
|
||||
|
||||
return evaluateDmDecision({
|
||||
groupPolicy,
|
||||
dmPolicy,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
isSenderAllowed,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- internal helpers ------------------------------------------------
|
||||
|
||||
interface DecisionContext {
|
||||
dmPolicy: QQBotDmPolicy;
|
||||
groupPolicy: QQBotGroupPolicy;
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
isSenderAllowed: (allowFrom: string[]) => boolean;
|
||||
}
|
||||
|
||||
function evaluateGroupDecision(ctx: DecisionContext): QQBotAccessResult {
|
||||
const base = buildResultBase(ctx);
|
||||
|
||||
if (ctx.groupPolicy === "disabled") {
|
||||
return {
|
||||
...base,
|
||||
decision: "block",
|
||||
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED,
|
||||
reason: "groupPolicy=disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.groupPolicy === "open") {
|
||||
return {
|
||||
...base,
|
||||
decision: "allow",
|
||||
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
|
||||
reason: "groupPolicy=open",
|
||||
};
|
||||
}
|
||||
|
||||
// groupPolicy === "allowlist"
|
||||
if (ctx.effectiveGroupAllowFrom.length === 0) {
|
||||
return {
|
||||
...base,
|
||||
decision: "block",
|
||||
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
|
||||
reason: "groupPolicy=allowlist (empty allowlist)",
|
||||
};
|
||||
}
|
||||
|
||||
if (!ctx.isSenderAllowed(ctx.effectiveGroupAllowFrom)) {
|
||||
return {
|
||||
...base,
|
||||
decision: "block",
|
||||
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
|
||||
reason: "groupPolicy=allowlist (not allowlisted)",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
decision: "allow",
|
||||
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
|
||||
reason: "groupPolicy=allowlist (allowlisted)",
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateDmDecision(ctx: DecisionContext): QQBotAccessResult {
|
||||
const base = buildResultBase(ctx);
|
||||
|
||||
if (ctx.dmPolicy === "disabled") {
|
||||
return {
|
||||
...base,
|
||||
decision: "block",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_DISABLED,
|
||||
reason: "dmPolicy=disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.dmPolicy === "open") {
|
||||
if (ctx.effectiveAllowFrom.includes("*")) {
|
||||
return {
|
||||
...base,
|
||||
decision: "allow",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
|
||||
reason: "dmPolicy=open",
|
||||
};
|
||||
}
|
||||
if (ctx.isSenderAllowed(ctx.effectiveAllowFrom)) {
|
||||
return {
|
||||
...base,
|
||||
decision: "allow",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
|
||||
reason: "dmPolicy=open (allowlisted)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
decision: "block",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
|
||||
reason: "dmPolicy=open (not allowlisted)",
|
||||
};
|
||||
}
|
||||
|
||||
// dmPolicy === "allowlist"
|
||||
if (ctx.effectiveAllowFrom.length === 0) {
|
||||
return {
|
||||
...base,
|
||||
decision: "block",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST,
|
||||
reason: "dmPolicy=allowlist (empty allowlist)",
|
||||
};
|
||||
}
|
||||
|
||||
if (!ctx.isSenderAllowed(ctx.effectiveAllowFrom)) {
|
||||
return {
|
||||
...base,
|
||||
decision: "block",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
|
||||
reason: "dmPolicy=allowlist (not allowlisted)",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
decision: "allow",
|
||||
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
|
||||
reason: "dmPolicy=allowlist (allowlisted)",
|
||||
};
|
||||
}
|
||||
|
||||
function buildResultBase(
|
||||
ctx: DecisionContext,
|
||||
): Pick<
|
||||
QQBotAccessResult,
|
||||
"effectiveAllowFrom" | "effectiveGroupAllowFrom" | "dmPolicy" | "groupPolicy"
|
||||
> {
|
||||
return {
|
||||
effectiveAllowFrom: ctx.effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom: ctx.effectiveGroupAllowFrom,
|
||||
dmPolicy: ctx.dmPolicy,
|
||||
groupPolicy: ctx.groupPolicy,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,2 @@
|
||||
/**
|
||||
* QQBot inbound access control — public entry points.
|
||||
*
|
||||
* Consumers (inbound-pipeline and future adapters) should import from
|
||||
* this barrel to keep the internal module layout opaque.
|
||||
*/
|
||||
|
||||
export { resolveQQBotAccess } from "./access-control.js";
|
||||
export { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js";
|
||||
export {
|
||||
type QQBotAccessDecision,
|
||||
type QQBotAccessReasonCode,
|
||||
type QQBotAccessResult,
|
||||
type QQBotDmPolicy,
|
||||
type QQBotGroupPolicy,
|
||||
} from "./types.js";
|
||||
export { type QQBotDmPolicy, type QQBotGroupPolicy } from "./types.js";
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
/**
|
||||
* Effective-policy resolver.
|
||||
*
|
||||
* Maps a raw `QQBotAccountConfig` to the concrete `dmPolicy`/`groupPolicy`
|
||||
* values that the access engine consumes. Provides backwards-compatible
|
||||
* defaults for accounts that only have the legacy `allowFrom` field:
|
||||
*
|
||||
* - Empty `allowFrom` or containing `"*"` → `"open"` (the historical
|
||||
* behaviour before P0/P1 landed).
|
||||
* - Non-empty `allowFrom` without `"*"` → `"allowlist"` (what a
|
||||
* security-conscious operator almost certainly meant).
|
||||
*
|
||||
* An explicit `dmPolicy`/`groupPolicy` always wins over the inference.
|
||||
*/
|
||||
|
||||
import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js";
|
||||
|
||||
/** Subset of the account config fields this resolver actually reads. */
|
||||
export interface EffectivePolicyInput {
|
||||
allowFrom?: Array<string | number> | null;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
@@ -27,17 +11,9 @@ function hasRealRestriction(list: Array<string | number> | null | undefined): bo
|
||||
if (!list || list.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// A list that only contains `"*"` is logically equivalent to open.
|
||||
return !list.every((entry) => String(entry).trim() === "*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the effective dmPolicy and groupPolicy applied at runtime.
|
||||
*
|
||||
* Caller should pass the raw `QQBotAccountConfig`. The resolver does
|
||||
* not look at `groups[id]` overrides — per-group overrides are layered
|
||||
* on top elsewhere (see `inbound-pipeline` mention gating).
|
||||
*/
|
||||
export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): {
|
||||
dmPolicy: QQBotDmPolicy;
|
||||
groupPolicy: QQBotGroupPolicy;
|
||||
@@ -47,9 +23,6 @@ export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): {
|
||||
|
||||
const dmPolicy: QQBotDmPolicy = input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open");
|
||||
|
||||
// groupPolicy defaults: if an explicit groupAllowFrom is provided and
|
||||
// restricts, enforce allowlist. Otherwise fall back to the same rule
|
||||
// as DM (so a single `allowFrom` entry locks down both DM and group).
|
||||
const groupPolicy: QQBotGroupPolicy =
|
||||
input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open");
|
||||
|
||||
|
||||
@@ -1,53 +1,2 @@
|
||||
/**
|
||||
* QQBot access-control primitive types.
|
||||
*
|
||||
* Mirrors the semantics of the framework-shared `DmPolicy` and
|
||||
* `DmGroupAccessDecision` types while staying zero-dependency so the
|
||||
* engine layer remains portable across the built-in and standalone
|
||||
* plugin builds.
|
||||
*
|
||||
* The reason codes here intentionally match
|
||||
* `src/security/dm-policy-shared.ts::DM_GROUP_ACCESS_REASON` so metric
|
||||
* dashboards can treat QQBot decisions identically to WhatsApp /
|
||||
* Telegram / Discord decisions.
|
||||
*/
|
||||
|
||||
/** DM-level policy selecting between open / allowlist / disabled gating. */
|
||||
export type QQBotDmPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
/** Group-level policy selecting between open / allowlist / disabled gating. */
|
||||
export type QQBotGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
/** High-level outcome returned by the access gate. */
|
||||
export type QQBotAccessDecision = "allow" | "block";
|
||||
|
||||
/** Structured reason codes used in logs and metrics. */
|
||||
export const QQBOT_ACCESS_REASON = {
|
||||
DM_POLICY_OPEN: "dm_policy_open",
|
||||
DM_POLICY_DISABLED: "dm_policy_disabled",
|
||||
DM_POLICY_ALLOWLISTED: "dm_policy_allowlisted",
|
||||
DM_POLICY_NOT_ALLOWLISTED: "dm_policy_not_allowlisted",
|
||||
DM_POLICY_EMPTY_ALLOWLIST: "dm_policy_empty_allowlist",
|
||||
GROUP_POLICY_ALLOWED: "group_policy_allowed",
|
||||
GROUP_POLICY_DISABLED: "group_policy_disabled",
|
||||
GROUP_POLICY_EMPTY_ALLOWLIST: "group_policy_empty_allowlist",
|
||||
GROUP_POLICY_NOT_ALLOWLISTED: "group_policy_not_allowlisted",
|
||||
BOT_SELF_ECHO: "bot_self_echo",
|
||||
} as const;
|
||||
|
||||
export type QQBotAccessReasonCode = (typeof QQBOT_ACCESS_REASON)[keyof typeof QQBOT_ACCESS_REASON];
|
||||
|
||||
/** Result of the access gate evaluation. */
|
||||
export interface QQBotAccessResult {
|
||||
decision: QQBotAccessDecision;
|
||||
reasonCode: QQBotAccessReasonCode;
|
||||
/** Human-readable reason suitable for logging. */
|
||||
reason: string;
|
||||
/** The allowFrom list that was actually compared against. */
|
||||
effectiveAllowFrom: string[];
|
||||
/** The groupAllowFrom list that was actually compared against. */
|
||||
effectiveGroupAllowFrom: string[];
|
||||
/** The dm/group policies that were used (after defaults were applied). */
|
||||
dmPolicy: QQBotDmPolicy;
|
||||
groupPolicy: QQBotGroupPolicy;
|
||||
}
|
||||
|
||||
@@ -1,126 +1,64 @@
|
||||
/**
|
||||
* Engine adapter layer — all external dependency interfaces unified here.
|
||||
*
|
||||
* This directory is the **single source of truth** for every interface
|
||||
* the engine uses to talk to the outside world.
|
||||
*
|
||||
* ## Two-layer DI architecture
|
||||
*
|
||||
* ### Layer 1: EngineAdapters (构造参数注入 — preferred)
|
||||
*
|
||||
* Used for capabilities consumed within the pipeline call stack.
|
||||
* Injected once via {@link CoreGatewayContext.adapters}, threaded
|
||||
* through {@link InboundPipelineDeps.adapters}, consumed by stages.
|
||||
*
|
||||
* - {@link HistoryPort} — group history record/build/clear
|
||||
* - {@link MentionGatePort} — mention + command gate evaluation
|
||||
* - {@link AudioConvertPort} — inbound SILK→WAV conversion
|
||||
* - {@link OutboundAudioPort} — outbound WAV→SILK conversion
|
||||
* - {@link CommandsPort} — slash-command version/approve dependencies
|
||||
*
|
||||
* ### Layer 2: PlatformAdapter (global singleton — leaf utilities)
|
||||
*
|
||||
* Used by leaf utility functions (`file-utils`, `image-size`,
|
||||
* `platform`, `config/resolve`) that sit outside the pipeline and
|
||||
* cannot receive a `deps` parameter. Registered once at startup.
|
||||
*
|
||||
* - {@link PlatformAdapter} — SSRF, secrets, media fetch, temp dir
|
||||
*/
|
||||
|
||||
import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { EffectivePolicyInput } from "../access/resolve-policy.js";
|
||||
import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js";
|
||||
|
||||
// ============ EngineAdapters (aggregated port injection) ============
|
||||
export type QQBotInboundAccess = ResolvedChannelMessageIngress;
|
||||
|
||||
export interface AccessPort {
|
||||
resolveInboundAccess(
|
||||
input: EffectivePolicyInput & {
|
||||
cfg: unknown;
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
},
|
||||
): QQBotInboundAccess | Promise<QQBotInboundAccess>;
|
||||
|
||||
resolveSlashCommandAuthorization(input: {
|
||||
cfg: unknown;
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
commandsAllowFrom?: Array<string | number>;
|
||||
}): boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated adapter ports injected via `CoreGatewayContext.adapters`.
|
||||
*
|
||||
* All fields are required — the bridge layer must provide every adapter.
|
||||
* The engine no longer falls back to built-in implementations.
|
||||
*/
|
||||
export interface EngineAdapters {
|
||||
/** Group history record/build/clear — backed by SDK `reply-history`. */
|
||||
history: import("./history.port.js").HistoryPort;
|
||||
/** Mention + command gate evaluation — backed by SDK `channel-mention-gating`. */
|
||||
mentionGate: import("./mention-gate.port.js").MentionGatePort;
|
||||
/** Inbound audio conversion (SILK→WAV, voice detection). */
|
||||
access: AccessPort;
|
||||
audioConvert: import("./audio.port.js").AudioConvertPort;
|
||||
/** Outbound audio conversion (WAV→SILK, audio detection). */
|
||||
outboundAudio: import("./audio.port.js").OutboundAudioPort;
|
||||
/** Slash-command dependencies (version, approve runtime). */
|
||||
commands: import("./commands.port.js").CommandsPort;
|
||||
}
|
||||
|
||||
// ============ PlatformAdapter (global singleton — leaf utilities) ============
|
||||
|
||||
/** Platform adapter that leaf utilities use for framework-specific operations. */
|
||||
export interface PlatformAdapter {
|
||||
/** Validate that a remote URL is safe to fetch (SSRF protection). */
|
||||
validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise<void>;
|
||||
|
||||
/** Resolve a secret value (SecretInput or plain string) to a plain string. */
|
||||
resolveSecret(value: string | SecretInputRef | undefined): Promise<string | undefined>;
|
||||
|
||||
/** Download a remote file to a local directory. Returns the local file path. */
|
||||
downloadFile(url: string, destDir: string, filename?: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Fetch remote media with SSRF protection.
|
||||
* Replaces direct usage of `fetchRemoteMedia` from `plugin-sdk/media-runtime`.
|
||||
*/
|
||||
fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult>;
|
||||
|
||||
/** Return the preferred temporary directory for the platform. */
|
||||
getTempDir(): string;
|
||||
|
||||
/** Check whether a secret input value has been configured (non-empty). */
|
||||
hasConfiguredSecret(value: unknown): boolean;
|
||||
|
||||
/**
|
||||
* Normalize a raw SecretInput value into a plain string.
|
||||
* For unresolved references (e.g. `$secret:xxx`), returns the raw reference string.
|
||||
*/
|
||||
normalizeSecretInputString(value: unknown): string | undefined;
|
||||
|
||||
/**
|
||||
* Resolve a SecretInput value into the final plain-text secret.
|
||||
* For secret references, resolves them to actual values via the platform's secret store.
|
||||
*/
|
||||
resolveSecretInputString(params: { value: unknown; path: string }): string | undefined;
|
||||
|
||||
/**
|
||||
* Submit an approval decision to the framework's approval gateway.
|
||||
* Optional — only available when the framework supports approvals.
|
||||
* Returns true if the decision was submitted successfully.
|
||||
*/
|
||||
resolveApproval?(approvalId: string, decision: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
let _adapter: PlatformAdapter | null = null;
|
||||
let _adapterFactory: (() => PlatformAdapter) | null = null;
|
||||
|
||||
/** Register the platform adapter. Called once during startup. */
|
||||
export function registerPlatformAdapter(adapter: PlatformAdapter): void {
|
||||
_adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a factory that creates the PlatformAdapter on first access.
|
||||
*
|
||||
* This decouples adapter availability from side-effect import ordering.
|
||||
* The factory is invoked at most once — on the first `getPlatformAdapter()`
|
||||
* call when no adapter has been explicitly registered yet.
|
||||
*/
|
||||
export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void {
|
||||
_adapterFactory = factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registered platform adapter.
|
||||
*
|
||||
* If no adapter has been explicitly registered yet but a factory was provided
|
||||
* via `registerPlatformAdapterFactory()`, the factory is invoked to create
|
||||
* and register the adapter automatically.
|
||||
*/
|
||||
export function getPlatformAdapter(): PlatformAdapter {
|
||||
if (!_adapter && _adapterFactory) {
|
||||
_adapter = _adapterFactory();
|
||||
@@ -133,7 +71,6 @@ export function getPlatformAdapter(): PlatformAdapter {
|
||||
return _adapter;
|
||||
}
|
||||
|
||||
/** Check whether a platform adapter has been registered (or can be created from a factory). */
|
||||
export function hasPlatformAdapter(): boolean {
|
||||
return _adapter !== null || _adapterFactory !== null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Pre-dispatch authorization for requireAuth slash commands.
|
||||
*
|
||||
* Unlike the access-stage's `resolveCommandAuthorized` (which permits
|
||||
* `dm_policy_open` senders — i.e. anyone), this function requires the
|
||||
* sender to appear in an **explicit non-wildcard** allowFrom list.
|
||||
* Unlike the inbound message ingress command projection (which permits
|
||||
* open-policy chat senders), this function requires the sender to appear in an
|
||||
* **explicit non-wildcard** allowFrom list.
|
||||
*
|
||||
* Rationale: sensitive operations (log export, file deletion, approval
|
||||
* config changes) must be gated behind a deliberate operator decision.
|
||||
|
||||
@@ -25,6 +25,14 @@ export interface SlashCommandHandlerContext {
|
||||
log?: EngineLogger;
|
||||
getMessagePeerId: (msg: QueuedMessage) => string;
|
||||
getQueueSnapshot: (peerId: string) => QueueSnapshot;
|
||||
resolveCommandAuthorized?: (params: {
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
commandsAllowFrom?: Array<string | number>;
|
||||
}) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
// ============ Constants ============
|
||||
@@ -63,6 +71,24 @@ export async function trySlashCommand(
|
||||
// Normal slash command — try to match and execute.
|
||||
const receivedAt = Date.now();
|
||||
const peerId = ctx.getMessagePeerId(msg);
|
||||
const isGroup = msg.type === "group" || msg.type === "guild";
|
||||
const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg);
|
||||
const commandAuthorized = ctx.resolveCommandAuthorized
|
||||
? await ctx.resolveCommandAuthorized({
|
||||
isGroup,
|
||||
senderId: msg.senderId,
|
||||
conversationId: msg.groupOpenid ?? msg.channelId ?? msg.senderId,
|
||||
allowFrom: account.config?.allowFrom,
|
||||
groupAllowFrom: account.config?.groupAllowFrom,
|
||||
commandsAllowFrom,
|
||||
})
|
||||
: resolveSlashCommandAuth({
|
||||
senderId: msg.senderId,
|
||||
isGroup,
|
||||
allowFrom: account.config?.allowFrom,
|
||||
groupAllowFrom: account.config?.groupAllowFrom,
|
||||
commandsAllowFrom,
|
||||
});
|
||||
const cmdCtx: SlashCommandContext = {
|
||||
type: msg.type,
|
||||
senderId: msg.senderId,
|
||||
@@ -77,13 +103,7 @@ export async function trySlashCommand(
|
||||
accountId: account.accountId,
|
||||
appId: account.appId,
|
||||
accountConfig: account.config,
|
||||
commandAuthorized: resolveSlashCommandAuth({
|
||||
senderId: msg.senderId,
|
||||
isGroup: msg.type === "group" || msg.type === "guild",
|
||||
allowFrom: account.config?.allowFrom,
|
||||
groupAllowFrom: account.config?.groupAllowFrom,
|
||||
commandsAllowFrom: resolveQQBotCommandsAllowFrom(ctx.cfg),
|
||||
}),
|
||||
commandAuthorized,
|
||||
queueSnapshot: ctx.getQueueSnapshot(peerId),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
/**
|
||||
* QQBot group configuration resolution (pure logic).
|
||||
* QQBot 群配置解析(纯逻辑层)。
|
||||
*
|
||||
* Resolves per-group settings that the inbound pipeline needs to decide how
|
||||
* to gate and contextualize group messages. Reads from a raw config object
|
||||
* produced by the framework's config loader, with a `specific > wildcard
|
||||
* ("*") > default` precedence chain.
|
||||
*
|
||||
* All functions are **pure** (no I/O, no external state) — making them
|
||||
* portable to the standalone plugin build and trivially unit-testable.
|
||||
*/
|
||||
|
||||
import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js";
|
||||
import { resolveAccountBase } from "./resolve.js";
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
/**
|
||||
* Tool policy — which tool palette an agent should use in a given group.
|
||||
*
|
||||
* - `full`: default allow everything (no engine-side restriction).
|
||||
* - `restricted`: engine returns an empty allowlist so the framework falls
|
||||
* back to its built-in restricted palette.
|
||||
* - `none`: deny all tools.
|
||||
*/
|
||||
type GroupToolPolicy = "full" | "restricted" | "none";
|
||||
|
||||
/** Per-group configuration — everything that may be overridden per group. */
|
||||
interface GroupConfig {
|
||||
/** Whether the bot requires @mention to respond. Defaults to true. */
|
||||
requireMention: boolean;
|
||||
/**
|
||||
* When true, group messages that @other users (but not the bot) are
|
||||
* dropped silently without reaching the AI pipeline.
|
||||
*/
|
||||
ignoreOtherMentions: boolean;
|
||||
/** Tool palette policy. Defaults to "restricted". */
|
||||
toolPolicy: GroupToolPolicy;
|
||||
/** Human-readable group name. Empty string if not configured. */
|
||||
name: string;
|
||||
/** Per-group behaviour prompt appended to the system prompt. */
|
||||
prompt?: string;
|
||||
/**
|
||||
* Number of non-@ history messages buffered per group. Clamped to 0 when
|
||||
* disabled. The default matches the standalone build's `50`.
|
||||
*/
|
||||
historyLimit: number;
|
||||
}
|
||||
|
||||
// ============ Defaults ============
|
||||
|
||||
/** Default history limit — matches the standalone build. */
|
||||
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
||||
|
||||
/** Default group behaviour prompt. Exported so the gating stage can use
|
||||
* the same fallback when no per-group `prompt` is configured. */
|
||||
export const DEFAULT_GROUP_PROMPT =
|
||||
"If the sender is a bot, respond only when they explicitly @mention you to ask a question or request assistance with a specific task; keep your replies concise and clear, avoiding the urge to race other bots to answer or engage in lengthy, unproductive exchanges. In group chats, prioritize responding to messages from human users; bots should maintain a collaborative rather than competitive dynamic to ensure the conversation remains orderly and does not result in message flooding.";
|
||||
|
||||
@@ -66,9 +25,6 @@ const DEFAULT_GROUP_CONFIG: Readonly<Omit<GroupConfig, "prompt">> = {
|
||||
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
};
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
/** Read a named account's raw `groups` map from an OpenClawConfig. */
|
||||
function readGroupsMap(
|
||||
cfg: Record<string, unknown>,
|
||||
accountId?: string | null,
|
||||
@@ -78,7 +34,6 @@ function readGroupsMap(
|
||||
if (!groups) {
|
||||
return {};
|
||||
}
|
||||
// Only keep sub-objects; skip scalars produced by user mistakes.
|
||||
const normalized: Record<string, Record<string, unknown>> = {};
|
||||
for (const [key, value] of Object.entries(groups)) {
|
||||
const sub = asRecord(value);
|
||||
@@ -112,14 +67,6 @@ function readHistoryLimit(obj: Record<string, unknown>, key: string): number | u
|
||||
return Math.max(0, Math.floor(v));
|
||||
}
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
/**
|
||||
* Resolve per-group configuration with `specific > "*" > default` precedence.
|
||||
*
|
||||
* When `groupOpenid` is not provided, only the wildcard/default values are
|
||||
* returned. This lets callers query the "default" behaviour for new groups.
|
||||
*/
|
||||
export function resolveGroupConfig(
|
||||
cfg: Record<string, unknown>,
|
||||
groupOpenid?: string | null,
|
||||
@@ -151,7 +98,6 @@ export function resolveGroupConfig(
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the effective `historyLimit` (>= 0) for a given group. */
|
||||
export function resolveHistoryLimit(
|
||||
cfg: Record<string, unknown>,
|
||||
groupOpenid?: string | null,
|
||||
@@ -160,7 +106,6 @@ export function resolveHistoryLimit(
|
||||
return resolveGroupConfig(cfg, groupOpenid, accountId).historyLimit;
|
||||
}
|
||||
|
||||
/** Resolve `requireMention` for a given group. */
|
||||
export function resolveRequireMention(
|
||||
cfg: Record<string, unknown>,
|
||||
groupOpenid?: string | null,
|
||||
@@ -169,7 +114,6 @@ export function resolveRequireMention(
|
||||
return resolveGroupConfig(cfg, groupOpenid, accountId).requireMention;
|
||||
}
|
||||
|
||||
/** Resolve `ignoreOtherMentions` for a given group. */
|
||||
export function resolveIgnoreOtherMentions(
|
||||
cfg: Record<string, unknown>,
|
||||
groupOpenid?: string | null,
|
||||
@@ -234,13 +178,6 @@ interface GroupSettings {
|
||||
mentionPatterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all per-inbound group-related settings in one pass.
|
||||
*
|
||||
* Prefer this over calling `resolveHistoryLimit` / `resolveRequireMention`
|
||||
* / etc. individually in hot paths — each of those currently re-walks
|
||||
* the config tree on its own.
|
||||
*/
|
||||
export function resolveGroupSettings(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
groupOpenid: string;
|
||||
@@ -258,17 +195,10 @@ interface AgentEntry {
|
||||
groupChat?: { mentionPatterns?: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve mentionPatterns with `agent > global > []` precedence.
|
||||
*
|
||||
* Mirrors the framework's `messages.groupChat.mentionPatterns` / per-agent
|
||||
* `agents.list[].groupChat.mentionPatterns` chain.
|
||||
*/
|
||||
export function resolveMentionPatterns(
|
||||
cfg: Record<string, unknown>,
|
||||
agentId?: string | null,
|
||||
): string[] {
|
||||
// ---- 1. Agent-level ----
|
||||
if (agentId) {
|
||||
const agents = asRecord(cfg.agents);
|
||||
const list = Array.isArray(agents?.list) ? (agents?.list as AgentEntry[]) : [];
|
||||
@@ -284,7 +214,6 @@ export function resolveMentionPatterns(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2. Global level ----
|
||||
const messages = asRecord(cfg.messages);
|
||||
const globalGroupChat = asRecord(messages?.groupChat);
|
||||
if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
|
||||
@@ -294,6 +223,5 @@ export function resolveMentionPatterns(
|
||||
: [];
|
||||
}
|
||||
|
||||
// ---- 3. Default ----
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
/**
|
||||
* GatewayConnection — WebSocket lifecycle, heartbeat, reconnect, and session persistence.
|
||||
*
|
||||
* Encapsulates all connection state as class fields (replaces 11 closure variables).
|
||||
* Event handling and message processing are delegated to injected handlers.
|
||||
*/
|
||||
|
||||
import WebSocket from "ws";
|
||||
import type { EngineAdapters } from "../adapter/index.js";
|
||||
import {
|
||||
trySlashCommand,
|
||||
type SlashCommandHandlerContext,
|
||||
@@ -30,28 +24,21 @@ import { ReconnectState } from "./reconnect.js";
|
||||
import type { GatewayAccount, EngineLogger, GatewayPluginRuntime, WSPayload } from "./types.js";
|
||||
import { createQQWSClient } from "./ws-client.js";
|
||||
|
||||
// ============ Connection context ============
|
||||
|
||||
interface GatewayConnectionContext {
|
||||
account: GatewayAccount;
|
||||
abortSignal: AbortSignal;
|
||||
cfg: unknown;
|
||||
log?: EngineLogger;
|
||||
runtime: GatewayPluginRuntime;
|
||||
adapters: EngineAdapters;
|
||||
onReady?: (data: unknown) => void;
|
||||
/** Called when a RESUMED event is received (reconnect success). */
|
||||
onResumed?: (data: unknown) => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** Process a queued message (inbound pipeline → outbound dispatch). */
|
||||
handleMessage: (event: QueuedMessage) => Promise<void>;
|
||||
/** Called when an INTERACTION_CREATE event is received (e.g. approval button clicks). */
|
||||
onInteraction?: (event: InteractionEvent) => void;
|
||||
}
|
||||
|
||||
// ============ GatewayConnection ============
|
||||
|
||||
export class GatewayConnection {
|
||||
// ---- Connection state ----
|
||||
private isAborted = false;
|
||||
private currentWs: WebSocket | null = null;
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -75,7 +62,6 @@ export class GatewayConnection {
|
||||
});
|
||||
}
|
||||
|
||||
/** Start the connection loop. Resolves when abortSignal fires. */
|
||||
async start(): Promise<void> {
|
||||
this.restoreSession();
|
||||
this.registerAbortHandler();
|
||||
@@ -85,8 +71,6 @@ export class GatewayConnection {
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Session persistence ============
|
||||
|
||||
private restoreSession(): void {
|
||||
const { account, log } = this.ctx;
|
||||
const saved = loadSession(account.accountId, account.appId);
|
||||
@@ -113,8 +97,6 @@ export class GatewayConnection {
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Abort + cleanup ============
|
||||
|
||||
private registerAbortHandler(): void {
|
||||
const { account, abortSignal, log: _log } = this.ctx;
|
||||
abortSignal.addEventListener("abort", () => {
|
||||
@@ -145,8 +127,6 @@ export class GatewayConnection {
|
||||
this.currentWs = null;
|
||||
}
|
||||
|
||||
// ============ Reconnect ============
|
||||
|
||||
private scheduleReconnect(customDelay?: number): void {
|
||||
const { account: _account, log } = this.ctx;
|
||||
if (this.isAborted || this.reconnect.isExhausted()) {
|
||||
@@ -166,8 +146,6 @@ export class GatewayConnection {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ============ Connect ============
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
const { account, log } = this.ctx;
|
||||
|
||||
@@ -195,13 +173,18 @@ export class GatewayConnection {
|
||||
});
|
||||
this.currentWs = ws;
|
||||
|
||||
// ---- Slash command interception ----
|
||||
const slashCtx: SlashCommandHandlerContext = {
|
||||
account,
|
||||
cfg: this.ctx.cfg,
|
||||
log,
|
||||
getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg),
|
||||
getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId),
|
||||
resolveCommandAuthorized: (params) =>
|
||||
this.ctx.adapters.access.resolveSlashCommandAuthorization({
|
||||
cfg: this.ctx.cfg,
|
||||
accountId: account.accountId,
|
||||
...params,
|
||||
}),
|
||||
};
|
||||
|
||||
const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise<void> => {
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
/**
|
||||
* Core gateway entry point — thin shell that wires together:
|
||||
*
|
||||
* - GatewayConnection: WebSocket lifecycle, heartbeat, reconnect
|
||||
* - buildInboundContext: content building, attachments, quote resolution
|
||||
* - dispatchOutbound: AI dispatch, deliver callbacks, timeouts
|
||||
*
|
||||
* The only responsibilities of this file are:
|
||||
* 1. Initialize adapters from EngineAdapters
|
||||
* 2. Initialize API config + refIdx cache hook
|
||||
* 3. Create the message handler (inbound → outbound pipeline)
|
||||
* 4. Start GatewayConnection
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { initCommands } from "../commands/slash-commands-impl.js";
|
||||
import { createNodeSessionStoreReader } from "../group/activation.js";
|
||||
@@ -42,27 +28,18 @@ import type {
|
||||
} from "./types.js";
|
||||
import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
|
||||
|
||||
// Re-export context type for consumers.
|
||||
export type { CoreGatewayContext } from "./types.js";
|
||||
|
||||
// ============ startGateway ============
|
||||
|
||||
/**
|
||||
* Start the Gateway WebSocket connection with automatic reconnect support.
|
||||
*/
|
||||
export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
const { account, log, runtime, adapters } = ctx;
|
||||
|
||||
// ---- 1. Initialize adapters ----
|
||||
setOutboundAudioPort(adapters.outboundAudio);
|
||||
initCommands(adapters.commands);
|
||||
|
||||
// ---- 2. Validate ----
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
||||
}
|
||||
|
||||
// ---- 3. Diagnostics ----
|
||||
const diag = await runDiagnostics();
|
||||
if (diag.warnings.length > 0) {
|
||||
for (const w of diag.warnings) {
|
||||
@@ -70,11 +47,9 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 4. API config ----
|
||||
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
||||
log?.debug?.(`API config: markdownSupport=${account.markdownSupport}`);
|
||||
|
||||
// ---- 5. Outbound refIdx cache hook ----
|
||||
onMessageSent(account.appId, (refIdx, meta) => {
|
||||
log?.info(
|
||||
`onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`,
|
||||
@@ -105,7 +80,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 6. Group support (per-connection state) ----
|
||||
const groupOpts = {
|
||||
enabled: ctx.group?.enabled ?? true,
|
||||
allowTextCommands: ctx.group?.allowTextCommands,
|
||||
@@ -121,7 +95,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
? (groupOpts.sessionStoreReader ?? createNodeSessionStoreReader())
|
||||
: undefined;
|
||||
|
||||
// ---- 7. Message handler ----
|
||||
const handleMessage = async (event: QueuedMessage): Promise<void> => {
|
||||
log?.info(`Processing message from ${event.senderId}: ${event.content}`, {
|
||||
accountId: account.accountId,
|
||||
@@ -161,9 +134,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group gate decided to stop early (drop_other_mention, block, skip
|
||||
// no-mention). History has already been recorded inside the
|
||||
// pipeline; there is no outbound to dispatch.
|
||||
if (inbound.skipped) {
|
||||
log?.info(
|
||||
`Skipped group inbound: reason=${inbound.skipReason ?? "unknown"} group=${event.groupOpenid ?? ""}`,
|
||||
@@ -192,9 +162,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
log?.error(`Message processing failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
inbound.typing.keepAlive?.stop();
|
||||
// Reset the buffered non-@ chatter after every @-activation turn
|
||||
// (success or failure), matching the standalone build. Guards
|
||||
// against stale history leaking into the next reply.
|
||||
if (event.type === "group" && event.groupOpenid && inbound.group) {
|
||||
clearGroupPendingHistory({
|
||||
historyMap: groupHistories,
|
||||
@@ -206,16 +173,15 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 8. Interaction handler ----
|
||||
const handleInteraction = createInteractionHandler(account, ctx.runtime, log);
|
||||
|
||||
// ---- 9. Start connection ----
|
||||
const connection = new GatewayConnection({
|
||||
account,
|
||||
abortSignal: ctx.abortSignal,
|
||||
cfg: ctx.cfg,
|
||||
log,
|
||||
runtime,
|
||||
adapters,
|
||||
onReady: ctx.onReady,
|
||||
onResumed: ctx.onResumed,
|
||||
onError: ctx.onError,
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
/**
|
||||
* InboundContext — the structured result of the inbound pipeline.
|
||||
*
|
||||
* Connects the inbound stage (content building, attachment processing,
|
||||
* quote resolution) with the outbound stage (AI dispatch, deliver callbacks).
|
||||
*
|
||||
* All fields are readonly after construction. The outbound dispatcher
|
||||
* reads from this object but never mutates it.
|
||||
*/
|
||||
|
||||
import type { QQBotAccessDecision, QQBotAccessReasonCode } from "../access/index.js";
|
||||
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { EngineAdapters } from "../adapter/index.js";
|
||||
import type { GroupActivationMode, SessionStoreReader } from "../group/activation.js";
|
||||
import type { HistoryEntry } from "../group/history.js";
|
||||
import type { GroupMessageGateResult } from "../group/message-gating.js";
|
||||
import type { QueuedMessage } from "./message-queue.js";
|
||||
import type {
|
||||
GatewayAccount,
|
||||
EngineLogger,
|
||||
GatewayPluginRuntime,
|
||||
ProcessedAttachments,
|
||||
} from "./types.js";
|
||||
import type { GatewayAccount, EngineLogger, GatewayPluginRuntime } from "./types.js";
|
||||
import type { TypingKeepAlive } from "./typing-keepalive.js";
|
||||
|
||||
// ============ InboundContext ============
|
||||
|
||||
/** Quote (reply-to) metadata resolved during inbound processing. */
|
||||
export interface ReplyToInfo {
|
||||
id: string;
|
||||
body?: string;
|
||||
@@ -32,164 +14,69 @@ export interface ReplyToInfo {
|
||||
isQuote: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-specific inbound metadata.
|
||||
*
|
||||
* Populated for group / guild events; left `undefined` for DMs. Keeping
|
||||
* the group fields under a nested bag makes it obvious which fields are
|
||||
* safe to read only when `isGroupChat === true`.
|
||||
*
|
||||
* The shape is kept small on purpose: everything derivable from `gate`
|
||||
* (raw wasMentioned / explicit / implicit / hasAnyMention / bypass) is
|
||||
* stored once on `gate`, not duplicated on the outer object.
|
||||
*/
|
||||
export interface InboundGroupInfo {
|
||||
// ---- Gating decision ----
|
||||
/** Full gate evaluation result (source of truth for mention state). */
|
||||
gate: GroupMessageGateResult;
|
||||
/** Effective activation mode after session-store / cfg merge. */
|
||||
activation: GroupActivationMode;
|
||||
|
||||
// ---- Persistence-relevant ----
|
||||
/** Per-group history buffer cap. Zero → disabled. */
|
||||
historyLimit: number;
|
||||
/** `true` if this message was built by merging several queued entries. */
|
||||
isMerged: boolean;
|
||||
/** The unfiltered list of queued messages when `isMerged`, else undefined. */
|
||||
mergedMessages?: readonly QueuedMessage[];
|
||||
|
||||
// ---- Presentation / prompt inputs ----
|
||||
/** Bundle of display-only strings; assembled by the envelope stage. */
|
||||
display: {
|
||||
/** Human-readable group name ("My Group" / first 8 chars of openid). */
|
||||
groupName: string;
|
||||
/** Sender label ("Nick (OPENID)" / "OPENID") for the UI. */
|
||||
senderLabel: string;
|
||||
/** Channel-level intro hint contributed by the platform adapter. */
|
||||
introHint?: string;
|
||||
/** Per-group behaviour prompt appended to the system prompt. */
|
||||
behaviorPrompt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Fully resolved inbound context passed to the outbound dispatcher. */
|
||||
export interface InboundContext {
|
||||
// ---- Original event ----
|
||||
event: QueuedMessage;
|
||||
|
||||
// ---- Routing ----
|
||||
route: { sessionKey: string; accountId: string; agentId?: string };
|
||||
isGroupChat: boolean;
|
||||
peerId: string;
|
||||
/** Fully qualified target address: "qqbot:c2c:xxx" / "qqbot:group:xxx" etc. */
|
||||
qualifiedTarget: string;
|
||||
fromAddress: string;
|
||||
|
||||
// ---- Content ----
|
||||
/** event.content after parseFaceTags. */
|
||||
parsedContent: string;
|
||||
/** parsedContent + voiceText + attachmentInfo — the user-visible text. */
|
||||
userContent: string;
|
||||
/** "[Quoted message begins]…[ends]" or empty. */
|
||||
quotePart: string;
|
||||
/** Per-message dynamic metadata lines (images, voice, ASR). */
|
||||
dynamicCtx: string;
|
||||
/** quotePart + userContent. */
|
||||
userMessage: string;
|
||||
/** dynamicCtx + userMessage (or raw content for slash commands). */
|
||||
agentBody: string;
|
||||
/** Formatted inbound envelope (Web UI body). */
|
||||
body: string;
|
||||
|
||||
// ---- System prompts ----
|
||||
systemPrompts: string[];
|
||||
groupSystemPrompt?: string;
|
||||
|
||||
// ---- Attachments ----
|
||||
attachments: ProcessedAttachments;
|
||||
localMediaPaths: string[];
|
||||
localMediaTypes: string[];
|
||||
remoteMediaUrls: string[];
|
||||
remoteMediaTypes: string[];
|
||||
|
||||
// ---- Voice ----
|
||||
uniqueVoicePaths: string[];
|
||||
uniqueVoiceUrls: string[];
|
||||
uniqueVoiceAsrReferTexts: string[];
|
||||
voiceMediaTypes: string[];
|
||||
hasAsrReferFallback: boolean;
|
||||
voiceTranscriptSources: string[];
|
||||
|
||||
// ---- Reply-to / Quote ----
|
||||
replyTo?: ReplyToInfo;
|
||||
|
||||
// ---- Auth ----
|
||||
commandAuthorized: boolean;
|
||||
|
||||
// ---- Group ----
|
||||
/** Populated only for group / guild messages. */
|
||||
group?: InboundGroupInfo;
|
||||
|
||||
// ---- Blocking / skipping ----
|
||||
/**
|
||||
* Whether the inbound message should be blocked outright (access policy
|
||||
* refused the sender). Mutually exclusive with `skipped`.
|
||||
*/
|
||||
blocked: boolean;
|
||||
/** Human-readable reason for `blocked`, for logging only. */
|
||||
blockReason?: string;
|
||||
/** Structured reason code for `blocked`. */
|
||||
blockReasonCode?: QQBotAccessReasonCode;
|
||||
/** The raw access decision produced by the policy engine. */
|
||||
accessDecision?: QQBotAccessDecision;
|
||||
/**
|
||||
* Whether the inbound was accepted by access control but stopped before
|
||||
* AI dispatch by the group gate (e.g. "skip_no_mention"). The caller
|
||||
* should NOT forward `skipped` messages to the outbound dispatcher, but
|
||||
* history / activity side-effects may already have been applied.
|
||||
*/
|
||||
blockReasonCode?: string;
|
||||
accessDecision?: ChannelIngressDecision["decision"];
|
||||
skipped: boolean;
|
||||
/** Structured reason code for `skipped`. */
|
||||
skipReason?: "drop_other_mention" | "block_unauthorized_command" | "skip_no_mention";
|
||||
|
||||
// ---- Typing ----
|
||||
typing: { keepAlive: TypingKeepAlive | null };
|
||||
/** refIdx returned by the initial InputNotify call. */
|
||||
inputNotifyRefIdx?: string;
|
||||
}
|
||||
|
||||
// ============ Pipeline dependencies ============
|
||||
|
||||
/** Dependencies injected into the inbound pipeline. */
|
||||
export interface InboundPipelineDeps {
|
||||
account: GatewayAccount;
|
||||
cfg: unknown;
|
||||
log?: EngineLogger;
|
||||
runtime: GatewayPluginRuntime;
|
||||
/** Start typing indicator and return the refIdx from InputNotify. */
|
||||
startTyping: (event: QueuedMessage) => Promise<{
|
||||
refIdx?: string;
|
||||
keepAlive: TypingKeepAlive | null;
|
||||
}>;
|
||||
// ---- Group dependencies (optional — omit when the caller doesn't need
|
||||
// group support, e.g. a DM-only test harness). ----
|
||||
/** Shared per-connection history buffer, created by the gateway. */
|
||||
groupHistories?: Map<string, HistoryEntry[]>;
|
||||
/** Session-store reader for activation-mode overrides. */
|
||||
sessionStoreReader?: SessionStoreReader;
|
||||
/** Whether text-based control commands are enabled globally. */
|
||||
allowTextCommands?: boolean;
|
||||
/**
|
||||
* Framework probe that returns true when `content` is a known control
|
||||
* command. Injected to avoid hard-coding a list of commands in engine.
|
||||
*/
|
||||
isControlCommand?: (content: string) => boolean;
|
||||
/** Optional platform hook that contributes a channel-level intro hint. */
|
||||
resolveGroupIntroHint?: (params: {
|
||||
cfg: unknown;
|
||||
accountId: string;
|
||||
groupId: string;
|
||||
}) => string | undefined;
|
||||
/** SDK adapter ports for delegating to shared implementations. */
|
||||
adapters: EngineAdapters;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QQBotInboundAccess } from "../adapter/index.js";
|
||||
import type { RefIndexEntry } from "../ref/types.js";
|
||||
import type { InboundPipelineDeps } from "./inbound-context.js";
|
||||
import { buildInboundContext } from "./inbound-pipeline.js";
|
||||
@@ -47,6 +48,28 @@ const account: GatewayAccount = {
|
||||
config: {},
|
||||
};
|
||||
|
||||
const emptyAllowlist: QQBotInboundAccess["state"]["allowlists"]["dm"] = {
|
||||
rawEntryCount: 0,
|
||||
normalizedEntries: [],
|
||||
invalidEntries: [],
|
||||
disabledEntries: [],
|
||||
matchedEntryIds: [],
|
||||
hasConfiguredEntries: false,
|
||||
hasMatchableEntries: false,
|
||||
hasWildcard: false,
|
||||
accessGroups: {
|
||||
referenced: [],
|
||||
matched: [],
|
||||
missing: [],
|
||||
unsupported: [],
|
||||
failed: [],
|
||||
},
|
||||
match: {
|
||||
matched: false,
|
||||
matchedEntryIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
function makeRuntime(): GatewayPluginRuntime {
|
||||
return {
|
||||
channel: {
|
||||
@@ -131,6 +154,63 @@ function makeDeps(overrides: Partial<InboundPipelineDeps> = {}): InboundPipeline
|
||||
implicitMention: false,
|
||||
})),
|
||||
},
|
||||
access: {
|
||||
resolveInboundAccess: vi.fn(
|
||||
(input): QQBotInboundAccess => ({
|
||||
state: {
|
||||
channelId: "qqbot",
|
||||
accountId: "qq-main",
|
||||
conversationKind: input.isGroup ? "group" : "direct",
|
||||
event: {
|
||||
kind: "message",
|
||||
authMode: "inbound",
|
||||
mayPair: true,
|
||||
hasOriginSubject: false,
|
||||
originSubjectMatched: false,
|
||||
},
|
||||
routeFacts: [],
|
||||
allowlists: {
|
||||
dm: emptyAllowlist,
|
||||
pairingStore: emptyAllowlist,
|
||||
group: emptyAllowlist,
|
||||
commandOwner: emptyAllowlist,
|
||||
commandGroup: emptyAllowlist,
|
||||
},
|
||||
},
|
||||
ingress: {
|
||||
admission: "dispatch",
|
||||
decision: "allow",
|
||||
decisiveGateId: "activation",
|
||||
reasonCode: "activation_allowed",
|
||||
graph: { gates: [] },
|
||||
},
|
||||
senderAccess: {
|
||||
allowed: true,
|
||||
decision: "allow",
|
||||
reasonCode: input.isGroup ? "group_policy_allowed" : "dm_policy_open",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
providerMissingFallbackApplied: false,
|
||||
},
|
||||
commandAccess: {
|
||||
requested: true,
|
||||
authorized: true,
|
||||
shouldBlockControlCommand: false,
|
||||
reasonCode: "command_authorized",
|
||||
},
|
||||
routeAccess: {
|
||||
allowed: true,
|
||||
},
|
||||
activationAccess: {
|
||||
ran: false,
|
||||
allowed: true,
|
||||
shouldSkip: false,
|
||||
reasonCode: "activation_allowed",
|
||||
},
|
||||
}),
|
||||
),
|
||||
resolveSlashCommandAuthorization: vi.fn(() => true),
|
||||
},
|
||||
audioConvert: {
|
||||
convertSilkToWav: vi.fn(async () => null),
|
||||
isVoiceAttachment: vi.fn(() => false),
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
/**
|
||||
* Inbound pipeline — compose stages into a single
|
||||
* {@link buildInboundContext} call.
|
||||
*
|
||||
* The pipeline stays intentionally thin: all real logic lives in
|
||||
* `./stages/*`. Reading this file top-to-bottom should be enough to
|
||||
* understand the full inbound path.
|
||||
*
|
||||
* Stage order:
|
||||
* 1. access — route + access control (early return on block)
|
||||
* 2. attachments — download + STT + image metadata
|
||||
* 3. typing — start the typing indicator (awaited before refIdx write)
|
||||
* 4. content — parseFaceTags + voice text + attachment info + mention cleanup
|
||||
* 5. quote — resolve `refMsgIdx` three ways
|
||||
* 6. refIdx — cache the current message so future quotes work
|
||||
* 7. group gate — @mention / ignoreOther / activation / command bypass
|
||||
* (early return on skip, history already recorded)
|
||||
* 8. envelope — body / quotePart / dynamicCtx
|
||||
* 9. assembly — userMessage + agentBody (with pending-history prefix)
|
||||
* 10. system — final group system prompt composition
|
||||
* 11. classify — media classification (local vs remote; dedup voice)
|
||||
*
|
||||
* Returns a fully populated {@link InboundContext}. The gateway handler
|
||||
* then branches on `blocked` / `skipped` to decide whether to dispatch
|
||||
* outbound.
|
||||
*/
|
||||
|
||||
import type { HistoryPort } from "../adapter/history.port.js";
|
||||
import type { HistoryEntry } from "../group/history.js";
|
||||
import { processAttachments } from "./inbound-attachments.js";
|
||||
@@ -40,36 +13,26 @@ import {
|
||||
buildUserContent,
|
||||
buildUserMessage,
|
||||
classifyMedia,
|
||||
resolveCommandAuthorized,
|
||||
resolveQuote,
|
||||
runAccessStage,
|
||||
runGroupGateStage,
|
||||
writeRefIndex,
|
||||
} from "./stages/index.js";
|
||||
|
||||
/**
|
||||
* Process a raw queued message through the full inbound pipeline.
|
||||
*
|
||||
* Returns an {@link InboundContext} with `blocked` / `skipped` set when
|
||||
* the message should not reach the AI dispatcher.
|
||||
*/
|
||||
export async function buildInboundContext(
|
||||
event: QueuedMessage,
|
||||
deps: InboundPipelineDeps,
|
||||
): Promise<InboundContext> {
|
||||
const { account, log } = deps;
|
||||
|
||||
// ---- 1. Access ----
|
||||
const accessResult = runAccessStage(event, deps);
|
||||
const accessResult = await runAccessStage(event, deps);
|
||||
if (accessResult.kind === "block") {
|
||||
return accessResult.context;
|
||||
}
|
||||
const { isGroupChat, peerId, qualifiedTarget, fromAddress, route, access } = accessResult;
|
||||
|
||||
// ---- 2. Typing indicator (async; awaited before refIdx write) ----
|
||||
const typingPromise = deps.startTyping(event);
|
||||
|
||||
// ---- 3. Attachments ----
|
||||
const processed = await processAttachments(event.attachments, {
|
||||
accountId: account.accountId,
|
||||
cfg: deps.cfg,
|
||||
@@ -77,17 +40,14 @@ export async function buildInboundContext(
|
||||
log,
|
||||
});
|
||||
|
||||
// ---- 4. Content ----
|
||||
const { parsedContent, userContent } = buildUserContent({
|
||||
event,
|
||||
attachmentInfo: processed.attachmentInfo,
|
||||
voiceTranscripts: processed.voiceTranscripts,
|
||||
});
|
||||
|
||||
// ---- 5. Quote ----
|
||||
const replyTo = await resolveQuote(event, deps);
|
||||
|
||||
// ---- 6. RefIdx ----
|
||||
const typingResult = await typingPromise;
|
||||
writeRefIndex({
|
||||
event,
|
||||
@@ -96,7 +56,6 @@ export async function buildInboundContext(
|
||||
inputNotifyRefIdx: typingResult.refIdx,
|
||||
});
|
||||
|
||||
// ---- 7. Group gate ----
|
||||
let groupInfo: InboundContext["group"];
|
||||
if (event.type === "group" && event.groupOpenid) {
|
||||
const gateOutcome = runGroupGateStage({
|
||||
@@ -107,6 +66,7 @@ export async function buildInboundContext(
|
||||
sessionKey: route.sessionKey,
|
||||
userContent,
|
||||
processedAttachments: processed,
|
||||
access,
|
||||
});
|
||||
|
||||
if (gateOutcome.kind === "skip") {
|
||||
@@ -128,7 +88,6 @@ export async function buildInboundContext(
|
||||
groupInfo = gateOutcome.groupInfo;
|
||||
}
|
||||
|
||||
// ---- 8. Envelope ----
|
||||
const body = buildBody({
|
||||
event,
|
||||
deps,
|
||||
@@ -145,7 +104,6 @@ export async function buildInboundContext(
|
||||
uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
|
||||
});
|
||||
|
||||
// ---- 9. Assembly ----
|
||||
const userMessage = buildUserMessage({
|
||||
event,
|
||||
userContent,
|
||||
@@ -163,17 +121,9 @@ export async function buildInboundContext(
|
||||
deps,
|
||||
});
|
||||
|
||||
// ---- 10. System prompt ----
|
||||
const systemPrompts: string[] = [];
|
||||
if (account.systemPrompt) {
|
||||
systemPrompts.push(account.systemPrompt);
|
||||
}
|
||||
const accountSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
|
||||
const accountSystemInstruction = account.systemPrompt ?? "";
|
||||
const groupSystemPrompt = buildGroupSystemPrompt(accountSystemInstruction, groupInfo);
|
||||
|
||||
// ---- 11. Authorization ----
|
||||
const commandAuthorized = resolveCommandAuthorized(access);
|
||||
|
||||
return {
|
||||
event,
|
||||
route,
|
||||
@@ -181,20 +131,12 @@ export async function buildInboundContext(
|
||||
peerId,
|
||||
qualifiedTarget,
|
||||
fromAddress,
|
||||
parsedContent,
|
||||
userContent,
|
||||
quotePart,
|
||||
dynamicCtx,
|
||||
userMessage,
|
||||
agentBody,
|
||||
body,
|
||||
systemPrompts,
|
||||
groupSystemPrompt,
|
||||
attachments: processed,
|
||||
localMediaPaths: media.localMediaPaths,
|
||||
localMediaTypes: media.localMediaTypes,
|
||||
remoteMediaUrls: media.remoteMediaUrls,
|
||||
remoteMediaTypes: media.remoteMediaTypes,
|
||||
uniqueVoicePaths: media.uniqueVoicePaths,
|
||||
uniqueVoiceUrls: media.uniqueVoiceUrls,
|
||||
uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
|
||||
@@ -202,22 +144,16 @@ export async function buildInboundContext(
|
||||
hasAsrReferFallback: media.hasAsrReferFallback,
|
||||
voiceTranscriptSources: media.voiceTranscriptSources,
|
||||
replyTo,
|
||||
commandAuthorized,
|
||||
commandAuthorized: access.commandAccess.authorized,
|
||||
group: groupInfo,
|
||||
blocked: false,
|
||||
skipped: false,
|
||||
accessDecision: access.decision,
|
||||
accessDecision: access.senderAccess.decision,
|
||||
typing: { keepAlive: typingResult.keepAlive },
|
||||
inputNotifyRefIdx: typingResult.refIdx,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Public history-clear helper ============
|
||||
|
||||
/**
|
||||
* Clear a group's pending history buffer. Exposed so the gateway can
|
||||
* call it in its `finally` block after a reply attempt.
|
||||
*/
|
||||
export function clearGroupPendingHistory(params: {
|
||||
historyMap: Map<string, HistoryEntry[]> | undefined;
|
||||
groupOpenid: string | undefined;
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
/**
|
||||
* Per-user concurrent message queue.
|
||||
*
|
||||
* Messages are serialized per **peer** (one DM user, one group, one guild
|
||||
* channel) and processed in parallel across peers up to
|
||||
* {@link DEFAULT_MAX_CONCURRENT_USERS}.
|
||||
*
|
||||
* Group-specific enhancements (added when merging from the standalone build):
|
||||
* - Group peers have a larger queue cap ({@link DEFAULT_GROUP_QUEUE_SIZE})
|
||||
* because groups can burst more chatter than a single DM.
|
||||
* - When a group's queue overflows, bot-authored messages are evicted
|
||||
* preferentially so human messages don't get dropped.
|
||||
* - When draining a group peer with more than one queued message, the
|
||||
* non-command messages are **merged** into one logical turn (see
|
||||
* {@link mergeGroupMessages}). Slash commands are always processed
|
||||
* individually to avoid conflating a "/stop" with surrounding chatter.
|
||||
*
|
||||
* The module is self-contained: the only injected dependency is the
|
||||
* logger / abort probe supplied via {@link MessageQueueContext}.
|
||||
*/
|
||||
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
|
||||
// ============ Queue limits ============
|
||||
|
||||
/** Global cap across all peers. */
|
||||
const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
|
||||
/** Per-DM / per-channel cap. */
|
||||
const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
|
||||
/** Per-group cap — larger because groups burst more. */
|
||||
const DEFAULT_GROUP_QUEUE_SIZE = 50;
|
||||
/** Parallel fanout across peers. */
|
||||
const DEFAULT_MAX_CONCURRENT_USERS = 10;
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
/** Mention entry carried on group messages (subset of QQ's shape). */
|
||||
export interface QueuedMention {
|
||||
scope?: "all" | "single";
|
||||
id?: string;
|
||||
@@ -46,28 +16,15 @@ export interface QueuedMention {
|
||||
is_you?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata attached to a merged group turn.
|
||||
*
|
||||
* When the drainer folds multiple non-command messages into one
|
||||
* representative turn, the merge information lands here instead of
|
||||
* being scattered across `_` -prefixed fields on {@link QueuedMessage}.
|
||||
*/
|
||||
interface QueuedMergeInfo {
|
||||
/** Number of original messages folded in. Always >= 2. */
|
||||
count: number;
|
||||
/** Original messages in insertion order — `messages.at(-1)` is "current". */
|
||||
messages: readonly QueuedMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue item used for asynchronous message handling without blocking heartbeats.
|
||||
*/
|
||||
export interface QueuedMessage {
|
||||
type: "c2c" | "guild" | "dm" | "group";
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
/** Whether the sender is another bot. Used by the eviction policy. */
|
||||
senderIsBot?: boolean;
|
||||
content: string;
|
||||
messageId: string;
|
||||
@@ -82,13 +39,9 @@ export interface QueuedMessage {
|
||||
voice_wav_url?: string;
|
||||
asr_refer_text?: string;
|
||||
}>;
|
||||
/** refIdx of the quoted message. */
|
||||
refMsgIdx?: string;
|
||||
/** refIdx assigned to this message for future quoting. */
|
||||
msgIdx?: string;
|
||||
/** QQ message type (103 = quote). */
|
||||
msgType?: number;
|
||||
/** Referenced message elements (for quote messages). */
|
||||
msgElements?: Array<{
|
||||
msg_idx?: string;
|
||||
content?: string;
|
||||
@@ -103,26 +56,12 @@ export interface QueuedMessage {
|
||||
asr_refer_text?: string;
|
||||
}>;
|
||||
}>;
|
||||
/**
|
||||
* Raw event type (e.g. `GROUP_AT_MESSAGE_CREATE`). Used by the gate to
|
||||
* detect explicit @bot without parsing `mentions` ourselves, and by
|
||||
* the group merger to decide whether the merged result represents an
|
||||
* @bot turn.
|
||||
*/
|
||||
eventType?: string;
|
||||
/** @mentions list from the raw event. */
|
||||
mentions?: QueuedMention[];
|
||||
/** Scene info (source channel + ext bag). */
|
||||
messageScene?: { source?: string; ext?: string[] };
|
||||
|
||||
/**
|
||||
* Set only on merged group turns; absent on single-message turns.
|
||||
* See {@link mergeGroupMessages} for merge semantics.
|
||||
*/
|
||||
merge?: QueuedMergeInfo;
|
||||
}
|
||||
|
||||
/** Convenience predicate: is this a merged multi-message turn? */
|
||||
export function isMergedTurn(msg: QueuedMessage): msg is QueuedMessage & {
|
||||
merge: QueuedMergeInfo;
|
||||
} {
|
||||
@@ -136,19 +75,13 @@ interface MessageQueueContext {
|
||||
error: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
debug?: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
/** Abort-state probe supplied by the caller. */
|
||||
isAborted: () => boolean;
|
||||
/** Per-group queue cap. Defaults to {@link DEFAULT_GROUP_QUEUE_SIZE}. */
|
||||
groupQueueSize?: number;
|
||||
/** Per-DM / per-channel queue cap. Defaults to {@link DEFAULT_PER_PEER_QUEUE_SIZE}. */
|
||||
peerQueueSize?: number;
|
||||
/** Global queue cap. Defaults to {@link DEFAULT_GLOBAL_QUEUE_SIZE}. */
|
||||
globalQueueSize?: number;
|
||||
/** Max concurrent peers. Defaults to {@link DEFAULT_MAX_CONCURRENT_USERS}. */
|
||||
maxConcurrentUsers?: number;
|
||||
}
|
||||
|
||||
/** Snapshot of the queue state for diagnostics. */
|
||||
interface QueueSnapshot {
|
||||
totalPending: number;
|
||||
activeUsers: number;
|
||||
@@ -161,20 +94,14 @@ interface MessageQueue {
|
||||
startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise<void>) => void;
|
||||
getSnapshot: (senderPeerId: string) => QueueSnapshot;
|
||||
getMessagePeerId: (msg: QueuedMessage) => string;
|
||||
/** Clear a user's queued messages and return how many were dropped. */
|
||||
clearUserQueue: (peerId: string) => number;
|
||||
/** Execute one message immediately, bypassing the queue for urgent commands. */
|
||||
executeImmediate: (msg: QueuedMessage) => void;
|
||||
}
|
||||
|
||||
// ============ Group merging ============
|
||||
|
||||
/** Return true when the peer id refers to a group-like conversation. */
|
||||
function isGroupPeer(peerId: string): boolean {
|
||||
return peerId.startsWith("group:") || peerId.startsWith("guild:");
|
||||
}
|
||||
|
||||
/** Slash-command test used by {@link drainGroupBatch}. */
|
||||
function isSlashCommand(msg: QueuedMessage): boolean {
|
||||
return (msg.content ?? "").trim().startsWith("/");
|
||||
}
|
||||
@@ -264,11 +191,6 @@ export function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage {
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Queue factory ============
|
||||
|
||||
/**
|
||||
* Create a per-user concurrent queue with built-in group enhancements.
|
||||
*/
|
||||
export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
const { accountId: _accountId, log } = ctx;
|
||||
const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
|
||||
@@ -291,13 +213,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
return `dm:${msg.senderId}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evict one message from an over-full queue.
|
||||
*
|
||||
* For group peers we prefer to drop a bot-authored message so human
|
||||
* input never gets lost. Falling back to dropping the oldest keeps the
|
||||
* queue bounded when all members are bots.
|
||||
*/
|
||||
const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => {
|
||||
if (isGroup) {
|
||||
const botIdx = queue.findIndex((m) => m.senderIsBot);
|
||||
@@ -308,7 +223,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
return queue.shift();
|
||||
};
|
||||
|
||||
/** Run a single message, capturing errors in the log. */
|
||||
const processOne = async (msg: QueuedMessage, peerId: string, label: string): Promise<void> => {
|
||||
try {
|
||||
await handleMessageFnRef!(msg);
|
||||
@@ -317,11 +231,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drain a group's batch:
|
||||
* - slash commands are processed one by one (order preserved);
|
||||
* - the remaining messages are merged into a single turn.
|
||||
*/
|
||||
const drainGroupBatch = async (batch: QueuedMessage[], peerId: string): Promise<void> => {
|
||||
const commands: QueuedMessage[] = [];
|
||||
const normal: QueuedMessage[] = [];
|
||||
@@ -349,7 +258,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
}
|
||||
};
|
||||
|
||||
/** Process one peer's queue serially. */
|
||||
const drainUserQueue = async (peerId: string): Promise<void> => {
|
||||
if (activeUsers.has(peerId)) {
|
||||
return;
|
||||
@@ -370,7 +278,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
|
||||
try {
|
||||
while (queue.length > 0 && !ctx.isAborted()) {
|
||||
// Group peers with more than one queued message: batch-merge.
|
||||
if (isGroup && queue.length > 1 && handleMessageFnRef) {
|
||||
const batch = queue.splice(0);
|
||||
totalEnqueued = Math.max(0, totalEnqueued - batch.length);
|
||||
@@ -378,7 +285,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-message (or non-group) path.
|
||||
const msg = queue.shift()!;
|
||||
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
||||
if (handleMessageFnRef) {
|
||||
@@ -389,7 +295,6 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
activeUsers.delete(peerId);
|
||||
userQueues.delete(peerId);
|
||||
|
||||
// Fill any freed concurrency slots.
|
||||
for (const [waitingPeerId, waitingQueue] of userQueues) {
|
||||
if (activeUsers.size >= maxConcurrentUsers) {
|
||||
break;
|
||||
|
||||
@@ -59,29 +59,11 @@ function makeInbound(overrides: Partial<InboundContext> = {}): InboundContext {
|
||||
peerId: "user-openid",
|
||||
qualifiedTarget: "qqbot:c2c:user-openid",
|
||||
fromAddress: "qqbot:c2c:user-openid",
|
||||
parsedContent: "voice",
|
||||
userContent: "voice",
|
||||
quotePart: "",
|
||||
dynamicCtx: "",
|
||||
userMessage: "voice",
|
||||
agentBody: "voice",
|
||||
body: "voice",
|
||||
systemPrompts: [],
|
||||
attachments: {
|
||||
attachmentInfo: "",
|
||||
imageUrls: [],
|
||||
imageMediaTypes: [],
|
||||
voiceAttachmentPaths: [],
|
||||
voiceAttachmentUrls: [],
|
||||
voiceAsrReferTexts: [],
|
||||
voiceTranscripts: [],
|
||||
voiceTranscriptSources: [],
|
||||
attachmentLocalPaths: [],
|
||||
},
|
||||
localMediaPaths: [],
|
||||
localMediaTypes: [],
|
||||
remoteMediaUrls: [],
|
||||
remoteMediaTypes: [],
|
||||
uniqueVoicePaths: [],
|
||||
uniqueVoiceUrls: [],
|
||||
uniqueVoiceAsrReferTexts: [],
|
||||
@@ -249,9 +231,6 @@ describe("dispatchOutbound", () => {
|
||||
content: "/models",
|
||||
timestamp: "2026-04-25T00:00:00.000Z",
|
||||
},
|
||||
parsedContent: "/models",
|
||||
userContent: "/models",
|
||||
userMessage: "/models",
|
||||
agentBody: "/models",
|
||||
body: "/models",
|
||||
commandAuthorized: true,
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
/**
|
||||
* Access stage — resolves routing target + runs access control.
|
||||
*
|
||||
* Split from the pipeline so it is trivially unit-testable: given a raw
|
||||
* event and the runtime's routing info, the stage returns either:
|
||||
* - `{ kind: "allow", ... }` — proceed through the rest of the pipeline
|
||||
* - `{ kind: "block", context }` — short-circuit; the caller returns
|
||||
* `context` directly to its own caller.
|
||||
*/
|
||||
|
||||
import { resolveQQBotAccess, type QQBotAccessResult } from "../../access/index.js";
|
||||
import type { QQBotInboundAccess } from "../../adapter/index.js";
|
||||
import type { InboundContext, InboundPipelineDeps } from "../inbound-context.js";
|
||||
import type { QueuedMessage } from "../message-queue.js";
|
||||
import { buildBlockedInboundContext } from "./stub-contexts.js";
|
||||
|
||||
// ─────────────────────────── Types ───────────────────────────
|
||||
type AccessStageResult =
|
||||
| {
|
||||
kind: "allow";
|
||||
isGroupChat: boolean;
|
||||
peerId: string;
|
||||
qualifiedTarget: string;
|
||||
fromAddress: string;
|
||||
route: { sessionKey: string; accountId: string; agentId?: string };
|
||||
access: QQBotInboundAccess;
|
||||
}
|
||||
| { kind: "block"; context: InboundContext };
|
||||
|
||||
interface AccessStageAllow {
|
||||
kind: "allow";
|
||||
isGroupChat: boolean;
|
||||
peerId: string;
|
||||
qualifiedTarget: string;
|
||||
fromAddress: string;
|
||||
route: { sessionKey: string; accountId: string; agentId?: string };
|
||||
access: QQBotAccessResult;
|
||||
}
|
||||
|
||||
interface AccessStageBlock {
|
||||
kind: "block";
|
||||
context: InboundContext;
|
||||
}
|
||||
|
||||
type AccessStageResult = AccessStageAllow | AccessStageBlock;
|
||||
|
||||
// ─────────────────────────── Stage ───────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the routing target, walk the access policy, and decide whether
|
||||
* the inbound message should proceed to the rest of the pipeline.
|
||||
*/
|
||||
export function runAccessStage(event: QueuedMessage, deps: InboundPipelineDeps): AccessStageResult {
|
||||
export async function runAccessStage(
|
||||
event: QueuedMessage,
|
||||
deps: InboundPipelineDeps,
|
||||
): Promise<AccessStageResult> {
|
||||
const { account, cfg, runtime, log } = deps;
|
||||
|
||||
const isGroupChat = event.type === "guild" || event.type === "group";
|
||||
@@ -52,20 +32,22 @@ export function runAccessStage(event: QueuedMessage, deps: InboundPipelineDeps):
|
||||
peer: { kind: isGroupChat ? "group" : "direct", id: peerId },
|
||||
});
|
||||
|
||||
const access = resolveQQBotAccess({
|
||||
const access = await deps.adapters.access.resolveInboundAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
isGroup: isGroupChat,
|
||||
senderId: event.senderId,
|
||||
conversationId: peerId,
|
||||
allowFrom: account.config?.allowFrom,
|
||||
groupAllowFrom: account.config?.groupAllowFrom,
|
||||
dmPolicy: account.config?.dmPolicy,
|
||||
groupPolicy: account.config?.groupPolicy,
|
||||
});
|
||||
|
||||
if (access.decision !== "allow") {
|
||||
if (access.senderAccess.decision !== "allow") {
|
||||
log?.info(
|
||||
`Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` +
|
||||
`reason=${access.reason} senderId=${event.senderId} ` +
|
||||
`accountId=${account.accountId} isGroup=${isGroupChat}`,
|
||||
`Blocked qqbot inbound: decision=${access.senderAccess.decision} reasonCode=${access.senderAccess.reasonCode} ` +
|
||||
`senderId=${event.senderId} accountId=${account.accountId} isGroup=${isGroupChat}`,
|
||||
);
|
||||
return {
|
||||
kind: "block",
|
||||
@@ -103,7 +85,7 @@ function resolvePeerId(event: QueuedMessage, isGroupChat: boolean): string {
|
||||
}
|
||||
if (isGroupChat) {
|
||||
return "unknown";
|
||||
} // defensive, should never hit
|
||||
}
|
||||
return event.senderId;
|
||||
}
|
||||
|
||||
@@ -115,18 +97,3 @@ function buildQualifiedTarget(event: QueuedMessage, isGroupChat: boolean): strin
|
||||
}
|
||||
return event.type === "dm" ? `qqbot:dm:${event.guildId}` : `qqbot:c2c:${event.senderId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the access decision permits running text-based control
|
||||
* commands. Placed in the access stage because the rule is an
|
||||
* access-policy derivative, not a gate derivative.
|
||||
*/
|
||||
export function resolveCommandAuthorized(access: QQBotAccessResult): boolean {
|
||||
return (
|
||||
access.reasonCode === "dm_policy_open" ||
|
||||
access.reasonCode === "dm_policy_allowlisted" ||
|
||||
(access.reasonCode === "group_policy_allowed" &&
|
||||
access.effectiveGroupAllowFrom.length > 0 &&
|
||||
access.groupPolicy === "allowlist")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
/**
|
||||
* Group-gate stage — for `type === "group"` inbound events, decide
|
||||
* whether the message should pass to AI dispatch or be intercepted.
|
||||
*
|
||||
* Three possible outcomes:
|
||||
* - `{ kind: "pass", groupInfo }` — continue the pipeline
|
||||
* - `{ kind: "skip", groupInfo, skipReason }` — buffered to history
|
||||
* (if applicable) and short-circuit
|
||||
* - No group info at all — returned when the event isn't a group event
|
||||
* (caller should treat as a straight pass-through)
|
||||
*
|
||||
* Consolidates the control-command auth check, session-store
|
||||
* activation override, mention detection, and the unified
|
||||
* {@link resolveGroupMessageGate} call. Delegates all pure logic to
|
||||
* existing `engine/group/*` modules so this stage remains a thin
|
||||
* orchestrator.
|
||||
*/
|
||||
|
||||
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../../access/index.js";
|
||||
import type { HistoryPort } from "../../adapter/history.port.js";
|
||||
import type { QQBotInboundAccess } from "../../adapter/index.js";
|
||||
import type { MentionGatePort } from "../../adapter/mention-gate.port.js";
|
||||
import { DEFAULT_GROUP_PROMPT, resolveGroupSettings } from "../../config/group.js";
|
||||
import { resolveGroupActivation } from "../../group/activation.js";
|
||||
import { toAttachmentSummaries, type HistoryEntry } from "../../group/history.js";
|
||||
import { detectWasMentioned, hasAnyMention, resolveImplicitMention } from "../../group/mention.js";
|
||||
import type { GroupMessageGateResult } from "../../group/message-gating.js";
|
||||
import { getRefIndex } from "../../ref/store.js";
|
||||
import type { InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js";
|
||||
import type { InboundContext, InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js";
|
||||
import { isMergedTurn, type QueuedMessage } from "../message-queue.js";
|
||||
|
||||
// ─────────────────────────── Types ───────────────────────────
|
||||
|
||||
interface GroupGatePass {
|
||||
kind: "pass";
|
||||
groupInfo: InboundGroupInfo;
|
||||
@@ -35,7 +18,7 @@ interface GroupGatePass {
|
||||
interface GroupGateSkip {
|
||||
kind: "skip";
|
||||
groupInfo: InboundGroupInfo;
|
||||
skipReason: NonNullable<import("../inbound-context.js").InboundContext["skipReason"]>;
|
||||
skipReason: NonNullable<InboundContext["skipReason"]>;
|
||||
}
|
||||
|
||||
type GroupGateStageResult = GroupGatePass | GroupGateSkip;
|
||||
@@ -46,37 +29,21 @@ interface GroupGateStageInput {
|
||||
accountId: string;
|
||||
agentId?: string;
|
||||
sessionKey: string;
|
||||
/** User-visible content (post-emoji-parse, post-mention-strip). */
|
||||
userContent: string;
|
||||
/** Already-processed attachments (downloaded). Available for history recording. */
|
||||
processedAttachments?: import("../inbound-attachments.js").ProcessedAttachments;
|
||||
access: QQBotInboundAccess;
|
||||
}
|
||||
|
||||
// ─────────────────────────── Stage ───────────────────────────
|
||||
|
||||
/**
|
||||
* Run the group-gate stage.
|
||||
*
|
||||
* Precondition: `event.type === "group"` && `event.groupOpenid` is set.
|
||||
* The caller (pipeline) enforces this; the stage doesn't re-check.
|
||||
*
|
||||
* On `skip` outcomes the stage records the message into the group's
|
||||
* history buffer when the skip reason is one that should preserve
|
||||
* context (drop / skip_no_mention), then returns. `block` skip
|
||||
* reasons do NOT write history — they are silent rejects.
|
||||
*/
|
||||
export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageResult {
|
||||
const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input;
|
||||
const groupOpenid = event.groupOpenid!;
|
||||
const cfg = (deps.cfg ?? {}) as Record<string, unknown>;
|
||||
|
||||
// ---- 1. One-pass config resolution ----
|
||||
const settings = resolveGroupSettings({ cfg, groupOpenid, accountId, agentId });
|
||||
const { historyLimit, requireMention, ignoreOtherMentions } = settings.config;
|
||||
const behaviorPrompt = settings.config.prompt ?? DEFAULT_GROUP_PROMPT;
|
||||
const groupName = settings.name;
|
||||
|
||||
// ---- 2. Mention detection (QQ-specific) ----
|
||||
const explicitWasMentioned = detectWasMentioned({
|
||||
eventType: event.eventType,
|
||||
mentions: event.mentions as never,
|
||||
@@ -92,7 +59,6 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
|
||||
getRefEntry: (idx) => getRefIndex(idx) ?? null,
|
||||
});
|
||||
|
||||
// ---- 3. Activation mode (session store > cfg) ----
|
||||
const activation = resolveGroupActivation({
|
||||
cfg,
|
||||
agentId: agentId ?? "default",
|
||||
@@ -101,15 +67,11 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
|
||||
sessionStoreReader: deps.sessionStoreReader,
|
||||
});
|
||||
|
||||
// ---- 4. Command authorization (for bypass) ----
|
||||
const content = (event.content ?? "").trim();
|
||||
const isControlCommand = Boolean(deps.isControlCommand?.(content));
|
||||
const commandAuthorized =
|
||||
deps.allowTextCommands !== false && isSenderAllowedForCommands(event.senderId, deps);
|
||||
deps.allowTextCommands !== false && input.access.commandAccess.authorized;
|
||||
|
||||
// ---- 5. Gate evaluation ----
|
||||
// Layer 1 (ignoreOtherMentions) is QQ-specific and handled by
|
||||
// resolveGateWithPort. Layers 2+3 delegate to the SDK adapter.
|
||||
const gate = resolveGateWithPort({
|
||||
mentionGatePort: deps.adapters.mentionGate,
|
||||
ignoreOtherMentions,
|
||||
@@ -122,7 +84,6 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
|
||||
requireMention: activation === "mention",
|
||||
});
|
||||
|
||||
// ---- 6. Build InboundGroupInfo (shared by pass / skip paths) ----
|
||||
const introHint = deps.resolveGroupIntroHint?.({
|
||||
cfg,
|
||||
accountId,
|
||||
@@ -144,12 +105,10 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
|
||||
},
|
||||
};
|
||||
|
||||
// ---- 7. Decide pass vs skip ----
|
||||
if (gate.action === "pass") {
|
||||
return { kind: "pass", groupInfo };
|
||||
}
|
||||
|
||||
// Skip path: record history for drop / skip_no_mention, silent for block.
|
||||
if (gate.action === "drop_other_mention" || gate.action === "skip_no_mention") {
|
||||
recordGroupHistory({
|
||||
historyMap: deps.groupHistories,
|
||||
@@ -165,18 +124,6 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
|
||||
return { kind: "skip", groupInfo, skipReason: gate.action };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Internal helpers ───────────────────────────
|
||||
|
||||
import type { HistoryPort } from "../../adapter/history.port.js";
|
||||
import type { MentionGatePort } from "../../adapter/mention-gate.port.js";
|
||||
import type { GroupMessageGateResult } from "../../group/message-gating.js";
|
||||
|
||||
/**
|
||||
* Resolve the gate using the SDK MentionGatePort adapter.
|
||||
*
|
||||
* Layer 1 (ignoreOtherMentions) is QQ-specific and handled here.
|
||||
* Layers 2+3 delegate to the SDK's `resolveInboundMentionDecision`.
|
||||
*/
|
||||
function resolveGateWithPort(params: {
|
||||
mentionGatePort: MentionGatePort;
|
||||
ignoreOtherMentions: boolean;
|
||||
@@ -188,7 +135,6 @@ function resolveGateWithPort(params: {
|
||||
commandAuthorized: boolean;
|
||||
requireMention: boolean;
|
||||
}): GroupMessageGateResult {
|
||||
// Layer 1: QQ-specific ignoreOtherMentions
|
||||
if (
|
||||
params.ignoreOtherMentions &&
|
||||
params.hasAnyMention &&
|
||||
@@ -202,7 +148,6 @@ function resolveGateWithPort(params: {
|
||||
};
|
||||
}
|
||||
|
||||
// Layer 2+3: delegate to SDK mention gate (includes command bypass)
|
||||
const decision = params.mentionGatePort.resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: true,
|
||||
@@ -219,7 +164,6 @@ function resolveGateWithPort(params: {
|
||||
},
|
||||
});
|
||||
|
||||
// Map SDK's shouldBlock (unauthorized command) to our action
|
||||
if (params.allowTextCommands && params.isControlCommand && !params.commandAuthorized) {
|
||||
return {
|
||||
action: "block_unauthorized_command",
|
||||
@@ -243,18 +187,6 @@ function resolveGateWithPort(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether the sender is on the DM `allowFrom` list.
|
||||
*/
|
||||
function isSenderAllowedForCommands(senderId: string, deps: InboundPipelineDeps): boolean {
|
||||
const raw = deps.account.config?.allowFrom;
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeQQBotAllowFrom(raw);
|
||||
return createQQBotSenderMatcher(senderId)(normalized);
|
||||
}
|
||||
|
||||
function recordGroupHistory(params: {
|
||||
historyMap: Map<string, HistoryEntry[]> | undefined;
|
||||
groupOpenid: string;
|
||||
@@ -262,7 +194,6 @@ function recordGroupHistory(params: {
|
||||
event: QueuedMessage;
|
||||
userContent: string;
|
||||
historyPort: HistoryPort;
|
||||
/** Local paths from processAttachments — enriches history with downloaded file paths. */
|
||||
localPaths?: Array<string | null>;
|
||||
}): void {
|
||||
const { historyMap, groupOpenid, historyLimit, event, userContent, historyPort, localPaths } =
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
/**
|
||||
* Shared `InboundContext` stub builders for early-return paths.
|
||||
*
|
||||
* Both the access-control "blocked" path and the group-gate "skipped"
|
||||
* path need to return a fully populated {@link InboundContext} that the
|
||||
* upstream handler can inspect without crashing on undefined fields.
|
||||
* Centralising the stubs here prevents the two paths from drifting.
|
||||
*/
|
||||
|
||||
import type { QQBotAccessResult } from "../../access/index.js";
|
||||
import type { QQBotInboundAccess } from "../../adapter/index.js";
|
||||
import type { InboundContext, InboundGroupInfo } from "../inbound-context.js";
|
||||
import type { QueuedMessage } from "../message-queue.js";
|
||||
import type { TypingKeepAlive } from "../typing-keepalive.js";
|
||||
|
||||
/** Shared fields every stub context needs. */
|
||||
interface BaseStubFields {
|
||||
event: QueuedMessage;
|
||||
route: { sessionKey: string; accountId: string; agentId?: string };
|
||||
@@ -22,7 +12,6 @@ interface BaseStubFields {
|
||||
fromAddress: string;
|
||||
}
|
||||
|
||||
/** Build an {@link InboundContext} with all non-routing fields cleared. */
|
||||
function emptyInboundContext(fields: BaseStubFields): InboundContext {
|
||||
return {
|
||||
event: fields.event,
|
||||
@@ -31,30 +20,12 @@ function emptyInboundContext(fields: BaseStubFields): InboundContext {
|
||||
peerId: fields.peerId,
|
||||
qualifiedTarget: fields.qualifiedTarget,
|
||||
fromAddress: fields.fromAddress,
|
||||
parsedContent: "",
|
||||
userContent: "",
|
||||
quotePart: "",
|
||||
dynamicCtx: "",
|
||||
userMessage: "",
|
||||
agentBody: "",
|
||||
body: "",
|
||||
systemPrompts: [],
|
||||
groupSystemPrompt: undefined,
|
||||
attachments: {
|
||||
attachmentInfo: "",
|
||||
imageUrls: [],
|
||||
imageMediaTypes: [],
|
||||
voiceAttachmentPaths: [],
|
||||
voiceAttachmentUrls: [],
|
||||
voiceAsrReferTexts: [],
|
||||
voiceTranscripts: [],
|
||||
voiceTranscriptSources: [],
|
||||
attachmentLocalPaths: [],
|
||||
},
|
||||
localMediaPaths: [],
|
||||
localMediaTypes: [],
|
||||
remoteMediaUrls: [],
|
||||
remoteMediaTypes: [],
|
||||
uniqueVoicePaths: [],
|
||||
uniqueVoiceUrls: [],
|
||||
uniqueVoiceAsrReferTexts: [],
|
||||
@@ -71,35 +42,25 @@ function emptyInboundContext(fields: BaseStubFields): InboundContext {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link InboundContext} that represents a message blocked by
|
||||
* access control (policy denial, allowlist mismatch, etc.).
|
||||
*/
|
||||
export function buildBlockedInboundContext(
|
||||
params: BaseStubFields & {
|
||||
access: QQBotAccessResult;
|
||||
access: QQBotInboundAccess;
|
||||
},
|
||||
): InboundContext {
|
||||
return {
|
||||
...emptyInboundContext(params),
|
||||
blocked: true,
|
||||
blockReason: params.access.reason,
|
||||
blockReasonCode: params.access.reasonCode,
|
||||
accessDecision: params.access.decision,
|
||||
blockReason: params.access.senderAccess.reasonCode,
|
||||
blockReasonCode: params.access.senderAccess.reasonCode,
|
||||
accessDecision: params.access.senderAccess.decision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link InboundContext} that represents a message stopped by
|
||||
* the group gate (drop_other_mention, block_unauthorized_command,
|
||||
* skip_no_mention). Any history side-effects have already been applied
|
||||
* by the gate stage.
|
||||
*/
|
||||
export function buildSkippedInboundContext(
|
||||
params: BaseStubFields & {
|
||||
group: InboundGroupInfo;
|
||||
skipReason: NonNullable<InboundContext["skipReason"]>;
|
||||
access: QQBotAccessResult;
|
||||
access: QQBotInboundAccess;
|
||||
typing: { keepAlive: TypingKeepAlive | null };
|
||||
inputNotifyRefIdx?: string;
|
||||
},
|
||||
@@ -109,7 +70,7 @@ export function buildSkippedInboundContext(
|
||||
group: params.group,
|
||||
skipped: true,
|
||||
skipReason: params.skipReason,
|
||||
accessDecision: params.access.decision,
|
||||
accessDecision: params.access.senderAccess.decision,
|
||||
typing: params.typing,
|
||||
inputNotifyRefIdx: params.inputNotifyRefIdx,
|
||||
};
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
/**
|
||||
* Gateway types.
|
||||
*
|
||||
* core/gateway/gateway.ts now imports all dependencies directly (both
|
||||
* core/ modules and upper-layer files). The only injected dependency
|
||||
* is `runtime` (PluginRuntime), which is a framework-provided object.
|
||||
*/
|
||||
|
||||
// ============ Logger ============
|
||||
import type { EngineLogger } from "../types.js";
|
||||
export type { EngineLogger };
|
||||
|
||||
// ============ Account ============
|
||||
|
||||
/** Re-export GatewayAccount from engine/types.ts (single source of truth). */
|
||||
import type { GatewayAccount as _GatewayAccount } from "../types.js";
|
||||
export type GatewayAccount = _GatewayAccount;
|
||||
|
||||
// ============ PluginRuntime subset ============
|
||||
|
||||
/**
|
||||
* Subset of PluginRuntime used by the gateway.
|
||||
*
|
||||
* This is NOT a custom adapter — it's the exact same object shape that
|
||||
* the framework injects. We define it here so core/ doesn't need to
|
||||
* depend on the plugin-sdk root barrel.
|
||||
*/
|
||||
export interface GatewayPluginRuntime {
|
||||
channel: {
|
||||
activity: {
|
||||
@@ -80,13 +59,6 @@ export interface GatewayPluginRuntime {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* Config API for reading/writing the framework configuration.
|
||||
*
|
||||
* Used by the interaction handler (config query/update) directly
|
||||
* within the engine layer. Optional because not all runtime
|
||||
* environments provide config write capability.
|
||||
*/
|
||||
config?: {
|
||||
current: () => Record<string, unknown>;
|
||||
replaceConfigFile: (params: {
|
||||
@@ -96,12 +68,8 @@ export interface GatewayPluginRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Shared result types ============
|
||||
|
||||
/** Re-export ProcessedAttachments from inbound-attachments (single source of truth). */
|
||||
export type { ProcessedAttachments } from "./inbound-attachments.js";
|
||||
|
||||
/** Outbound result from media sends. */
|
||||
export interface OutboundResult {
|
||||
channel: string;
|
||||
messageId?: string;
|
||||
@@ -109,12 +77,8 @@ export interface OutboundResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Re-export RefAttachmentSummary for convenience. */
|
||||
export type { RefAttachmentSummary } from "../ref/types.js";
|
||||
|
||||
// ============ WebSocket Event Types ============
|
||||
|
||||
/** Raw WebSocket payload structure. */
|
||||
export interface WSPayload {
|
||||
op: number;
|
||||
d: unknown;
|
||||
@@ -122,7 +86,6 @@ export interface WSPayload {
|
||||
t?: string;
|
||||
}
|
||||
|
||||
/** Attachment shape shared by all message event types. */
|
||||
interface RawMessageAttachment {
|
||||
content_type: string;
|
||||
url: string;
|
||||
@@ -131,7 +94,6 @@ interface RawMessageAttachment {
|
||||
asr_refer_text?: string;
|
||||
}
|
||||
|
||||
/** Referenced message element (used for quote messages). */
|
||||
interface RawMsgElement {
|
||||
msg_idx?: string;
|
||||
content?: string;
|
||||
|
||||
@@ -1,36 +1,8 @@
|
||||
/**
|
||||
* Group activation mode — how the bot decides whether to respond in a group.
|
||||
*
|
||||
* Resolution chain:
|
||||
* 1. session store override (`/activation` command writes per-session
|
||||
* `groupActivation` value) — highest priority
|
||||
* 2. per-group `requireMention` config
|
||||
* 3. `"mention"` default (require @-bot to respond)
|
||||
*
|
||||
* File I/O is isolated in the default node-based reader so the gating
|
||||
* logic itself stays a pure function, testable without touching disk.
|
||||
*
|
||||
* Note: the implicit-mention predicate (quoting a bot message counts as
|
||||
* @-ing the bot) lives in `./mention.ts` alongside the other mention
|
||||
* helpers — see `resolveImplicitMention` there.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// ────────────────────────── Types ──────────────────────────
|
||||
|
||||
/** High-level activation outcome. */
|
||||
export type GroupActivationMode = "mention" | "always";
|
||||
|
||||
/**
|
||||
* Pluggable reader that returns parsed session-store contents.
|
||||
*
|
||||
* A return value of `null` means "no override available" (file missing,
|
||||
* parse error, or reader disabled). Implementations must **not** throw —
|
||||
* the gating pipeline treats any failure as "fall back to the config
|
||||
* default".
|
||||
*/
|
||||
export interface SessionStoreReader {
|
||||
read(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
@@ -38,22 +10,11 @@ export interface SessionStoreReader {
|
||||
}): Record<string, { groupActivation?: string }> | null;
|
||||
}
|
||||
|
||||
// ────────────────────────── groupActivation ──────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the effective activation mode for one inbound message.
|
||||
*
|
||||
* Order of precedence:
|
||||
* 1. `store[sessionKey].groupActivation` (read via the injected reader)
|
||||
* 2. config-level `requireMention` (maps to `"mention"` / `"always"`)
|
||||
* 3. `"mention"` (safe default)
|
||||
*/
|
||||
export function resolveGroupActivation(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
configRequireMention: boolean;
|
||||
/** Pluggable reader; omit to disable the session-store override. */
|
||||
sessionStoreReader?: SessionStoreReader;
|
||||
}): GroupActivationMode {
|
||||
const fallback: GroupActivationMode = params.configRequireMention ? "mention" : "always";
|
||||
@@ -78,16 +39,6 @@ export function resolveGroupActivation(params: {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ────────────────────────── Default node reader ──────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the on-disk path to the agent-sessions file.
|
||||
*
|
||||
* Priority:
|
||||
* 1. `cfg.session.store` (supports `{agentId}` placeholder and `~` expansion)
|
||||
* 2. `$OPENCLAW_STATE_DIR` / `$CLAWDBOT_STATE_DIR`
|
||||
* 3. `~/.openclaw/agents/{agentId}/sessions/sessions.json`
|
||||
*/
|
||||
function resolveSessionStorePath(
|
||||
cfg: Record<string, unknown>,
|
||||
agentId: string | undefined,
|
||||
@@ -119,16 +70,6 @@ function resolveSessionStorePath(
|
||||
return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the default, production-ready session-store reader.
|
||||
*
|
||||
* Reads the file synchronously on every call. The overhead is acceptable
|
||||
* because activation mode is only resolved once per group message and
|
||||
* the sessions file is typically a handful of kilobytes.
|
||||
*
|
||||
* Any I/O or JSON error is swallowed and returned as `null` so the
|
||||
* gating pipeline falls back to the config default.
|
||||
*/
|
||||
export function createNodeSessionStoreReader(): SessionStoreReader {
|
||||
return {
|
||||
read: ({ cfg, agentId }) => {
|
||||
|
||||
@@ -1,86 +1,28 @@
|
||||
/**
|
||||
* QQBot group @mention detection and text normalization.
|
||||
*
|
||||
* Pure functions extracted from the standalone build (`openclaw-qqbot/src/
|
||||
* channel.ts::detectWasMentioned` / `stripMentionText`) plus the helper
|
||||
* `hasAnyMention` that previously lived inline in `gateway.ts` and the
|
||||
* `resolveImplicitMention` predicate that decides whether a quoted-reply
|
||||
* should count as an implicit @bot.
|
||||
*
|
||||
* Keeping these helpers together makes it easier to test the group gating
|
||||
* pipeline and lets both the built-in and standalone builds share a
|
||||
* single mention-detection implementation.
|
||||
*/
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
/**
|
||||
* Raw mention entry shape used across QQ Bot group events.
|
||||
*
|
||||
* QQ's `mentions` array uses slightly different field names on different
|
||||
* event types (the bot's self-mention comes as `is_you: true`; user IDs
|
||||
* can appear in any of `member_openid` / `id` / `user_openid`). This type
|
||||
* captures the union so callers don't have to worry about which variant.
|
||||
*/
|
||||
export interface RawMention {
|
||||
/** Whether this mention targets the bot itself. */
|
||||
is_you?: boolean;
|
||||
/** Whether the mention target is another bot. */
|
||||
bot?: boolean;
|
||||
/** Member openid in group chats. */
|
||||
member_openid?: string;
|
||||
/** Event-level id (guild context). */
|
||||
id?: string;
|
||||
/** User openid (C2C context). */
|
||||
user_openid?: string;
|
||||
/** Display name. */
|
||||
nickname?: string;
|
||||
/** Alternative display name. */
|
||||
username?: string;
|
||||
/** @all / @single scope (QQ guild events). */
|
||||
scope?: "all" | "single";
|
||||
}
|
||||
|
||||
/** Input for {@link detectWasMentioned}. */
|
||||
interface DetectWasMentionedInput {
|
||||
/**
|
||||
* Raw event type. `"GROUP_AT_MESSAGE_CREATE"` unambiguously identifies
|
||||
* that the bot was @-ed, even when the mentions array is empty.
|
||||
*/
|
||||
eventType?: string;
|
||||
mentions?: RawMention[];
|
||||
/** Raw message content — used as a regex fallback via `mentionPatterns`. */
|
||||
content?: string;
|
||||
/**
|
||||
* Regex patterns matched against `content` when neither `mentions.is_you`
|
||||
* nor `eventType` prove a bot mention. Invalid patterns are ignored.
|
||||
*/
|
||||
mentionPatterns?: string[];
|
||||
}
|
||||
|
||||
/** Input for {@link hasAnyMention}. */
|
||||
interface HasAnyMentionInput {
|
||||
mentions?: RawMention[];
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// ============ Constants ============
|
||||
|
||||
/** Regex detecting `<@openid>` / `<@!openid>` mention tags in raw content. */
|
||||
const MENTION_TAG_RE = /<@!?\w+>/;
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
/**
|
||||
* Detect whether the inbound message explicitly targets the bot.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. `mentions[].is_you === true` (most reliable)
|
||||
* 2. `eventType === "GROUP_AT_MESSAGE_CREATE"` (QQ-level @bot event)
|
||||
* 3. regex match on any of `mentionPatterns` (fallback, e.g. "@bot-name")
|
||||
*
|
||||
* Returns `false` for direct messages or when no signal is found.
|
||||
*/
|
||||
export function detectWasMentioned(input: DetectWasMentionedInput): boolean {
|
||||
const { eventType, mentions, content, mentionPatterns } = input;
|
||||
|
||||
@@ -101,23 +43,13 @@ export function detectWasMentioned(input: DetectWasMentionedInput): boolean {
|
||||
if (new RegExp(pattern, "i").test(content)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex — skip silently; bad patterns must not crash the pipeline.
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether the message contains **any** @mention (not necessarily @bot).
|
||||
*
|
||||
* Used by the gating layer to decide whether to bypass mention requirements
|
||||
* for control commands. A control command like `/stop` that also @-s another
|
||||
* user should NOT bypass the mention gate — the `@other-user` prefix is a
|
||||
* strong signal that the command wasn't addressed to the bot.
|
||||
*/
|
||||
export function hasAnyMention(input: HasAnyMentionInput): boolean {
|
||||
if (input.mentions && input.mentions.length > 0) {
|
||||
return true;
|
||||
@@ -128,17 +60,6 @@ export function hasAnyMention(input: HasAnyMentionInput): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up `<@openid>` mention tags in raw QQ group content.
|
||||
*
|
||||
* - For the bot's own mention (`is_you === true`): the tag is removed
|
||||
* outright so prompts don't contain visible `<@BOTID>` garbage.
|
||||
* - For other mentioned users: the tag is replaced with `@nickname` (or
|
||||
* `@username`) for readability. Entries without a display name are left
|
||||
* as-is (rare in practice).
|
||||
*
|
||||
* Returns the original text unchanged when `text` or `mentions` is empty.
|
||||
*/
|
||||
export function stripMentionText(text: string, mentions?: RawMention[]): string {
|
||||
if (!text || !mentions?.length) {
|
||||
return text;
|
||||
@@ -149,7 +70,6 @@ export function stripMentionText(text: string, mentions?: RawMention[]): string
|
||||
if (!openid) {
|
||||
continue;
|
||||
}
|
||||
// RegExp: match both `<@openid>` and `<@!openid>` variants.
|
||||
const tagRe = new RegExp(`<@!?${escapeRegex(openid)}>`, "g");
|
||||
if (m.is_you) {
|
||||
cleaned = cleaned.replace(tagRe, "").trim();
|
||||
@@ -163,9 +83,6 @@ export function stripMentionText(text: string, mentions?: RawMention[]): string
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ============ Internal helpers ============
|
||||
|
||||
/** Escape characters that carry regex meaning. */
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -1,113 +1,27 @@
|
||||
/**
|
||||
* Group message gate — unified entry point for group inbound gating.
|
||||
*
|
||||
* Collapses three orthogonal rules that previously lived in ad-hoc spots
|
||||
* of the standalone gateway into a single pure function. Callers pass in
|
||||
* the message's mention state plus the resolved configuration, and get
|
||||
* back a structured action telling them how to handle the message.
|
||||
*
|
||||
* Evaluation order (short-circuit at the first match):
|
||||
* 1. `ignoreOtherMentions` — message @-s someone else but not the bot
|
||||
* → `drop_other_mention` (record to history,
|
||||
* then drop). Implicit mentions (e.g. quoting
|
||||
* a bot reply) still count as @bot.
|
||||
* 2. `block_unauthorized_command` — sender is not allowed to run control
|
||||
* commands (text starts with `/xxx`)
|
||||
* → silently drop.
|
||||
* 3. `mention gating` — when `requireMention` is on, non-@bot messages
|
||||
* are `skip_no_mention`'d (still buffered to
|
||||
* history). Authorized control commands can
|
||||
* **bypass** the gate as long as the message does
|
||||
* not @anyone else at the same time.
|
||||
* 4. Otherwise → `pass` (the message will reach the AI pipeline).
|
||||
*
|
||||
* All inputs are plain data; there is no I/O and no mutation, so the
|
||||
* function is safe to share between the built-in and standalone builds.
|
||||
*/
|
||||
|
||||
// ────────────────────── Types ──────────────────────
|
||||
|
||||
/**
|
||||
* Structured action returned by {@link resolveGroupMessageGate}.
|
||||
*
|
||||
* - `drop_other_mention` — message @-s another user but not the bot;
|
||||
* record to the group history cache and
|
||||
* drop without hitting the AI.
|
||||
* - `block_unauthorized_command` — silently refuse a control command from
|
||||
* an unauthorized sender (no history
|
||||
* write, no AI call).
|
||||
* - `skip_no_mention` — `requireMention` is on and the message
|
||||
* does not @bot; record to history but
|
||||
* skip AI dispatch.
|
||||
* - `pass` — forward the message to the AI pipeline.
|
||||
*/
|
||||
type GroupMessageGateAction =
|
||||
| "drop_other_mention"
|
||||
| "block_unauthorized_command"
|
||||
| "skip_no_mention"
|
||||
| "pass";
|
||||
|
||||
/** Gate evaluation result. */
|
||||
export interface GroupMessageGateResult {
|
||||
/** The action the caller should take. */
|
||||
action: GroupMessageGateAction;
|
||||
/**
|
||||
* Effective mention state after combining raw mention detection with
|
||||
* implicit / bypass signals. Only meaningful when `action === "pass"`.
|
||||
*/
|
||||
effectiveWasMentioned: boolean;
|
||||
/**
|
||||
* Whether the control-command bypass was applied to flip a missing
|
||||
* mention into `pass`. Only meaningful when `action === "pass"`.
|
||||
*/
|
||||
shouldBypassMention: boolean;
|
||||
}
|
||||
|
||||
/** Input for {@link resolveGroupMessageGate}. */
|
||||
export interface GroupMessageGateInput {
|
||||
// ---- ignoreOtherMentions layer ----
|
||||
/** Per-group config: drop messages that @someone other than the bot. */
|
||||
ignoreOtherMentions: boolean;
|
||||
/** Whether the message contains *any* @mention (including @other-user). */
|
||||
hasAnyMention: boolean;
|
||||
/**
|
||||
* Whether the QQ event explicitly @-s the bot (via `mentions[].is_you`
|
||||
* or `GROUP_AT_MESSAGE_CREATE`).
|
||||
*/
|
||||
wasMentioned: boolean;
|
||||
/**
|
||||
* Implicit mention — e.g. the message quotes an earlier bot reply.
|
||||
* Treated as equivalent to an explicit @bot for gating purposes.
|
||||
*/
|
||||
implicitMention: boolean;
|
||||
|
||||
// ---- Control-command layer ----
|
||||
/** Whether text-based control commands are enabled globally. */
|
||||
allowTextCommands: boolean;
|
||||
/** Whether the current message is recognised as a control command. */
|
||||
isControlCommand: boolean;
|
||||
/** Whether the sender is authorised to run control commands. */
|
||||
commandAuthorized: boolean;
|
||||
|
||||
// ---- Mention gating layer ----
|
||||
/** Per-group config: `requireMention` — bot only replies when @-ed. */
|
||||
requireMention: boolean;
|
||||
/**
|
||||
* Whether the channel can reliably detect @-mentions at all. In C2C chat
|
||||
* this should be `false` (DMs don't have mentions); in group chat it
|
||||
* should be `true`.
|
||||
*/
|
||||
canDetectMention: boolean;
|
||||
}
|
||||
|
||||
// ────────────────────── Core logic ──────────────────────
|
||||
|
||||
/**
|
||||
* Base mention-gate evaluation.
|
||||
*
|
||||
* `effectiveWasMentioned = wasMentioned || implicitMention || bypass`.
|
||||
* `shouldSkip = requireMention && canDetectMention && !effectiveWasMentioned`.
|
||||
*/
|
||||
function resolveMentionGating(input: {
|
||||
requireMention: boolean;
|
||||
canDetectMention: boolean;
|
||||
@@ -121,18 +35,6 @@ function resolveMentionGating(input: {
|
||||
return { effectiveWasMentioned, shouldSkip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an authorized control command may bypass the mention gate.
|
||||
*
|
||||
* All of the following must hold:
|
||||
* 1. `requireMention` is on (gate is active)
|
||||
* 2. The bot was NOT directly @-ed (otherwise no bypass is needed)
|
||||
* 3. The message does NOT @anyone (a `@other-user /stop` should NOT pass
|
||||
* — the command wasn't aimed at us)
|
||||
* 4. Text commands are enabled
|
||||
* 5. Sender is authorised
|
||||
* 6. The content is a valid control command
|
||||
*/
|
||||
function resolveCommandBypass(input: {
|
||||
requireMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
@@ -151,15 +53,7 @@ function resolveCommandBypass(input: {
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────── Unified gate ──────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate the group-message gate.
|
||||
*
|
||||
* See the module-level docs for the ordering and semantics.
|
||||
*/
|
||||
export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMessageGateResult {
|
||||
// ---- Layer 1: ignoreOtherMentions ----
|
||||
if (
|
||||
input.ignoreOtherMentions &&
|
||||
input.hasAnyMention &&
|
||||
@@ -173,7 +67,6 @@ export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMess
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Layer 2: unauthorized control command ----
|
||||
if (input.allowTextCommands && input.isControlCommand && !input.commandAuthorized) {
|
||||
return {
|
||||
action: "block_unauthorized_command",
|
||||
@@ -182,7 +75,6 @@ export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMess
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Layer 3: mention gate + command bypass ----
|
||||
const shouldBypassMention = resolveCommandBypass({
|
||||
requireMention: input.requireMention,
|
||||
wasMentioned: input.wasMentioned,
|
||||
|
||||
Reference in New Issue
Block a user