refactor: normalize group session keys
This commit is contained in:
@@ -220,6 +220,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
},
|
||||
{},
|
||||
@@ -230,7 +231,7 @@ describe("trigger handling", () => {
|
||||
const store = JSON.parse(
|
||||
await fs.readFile(cfg.session.store, "utf-8"),
|
||||
) as Record<string, { groupActivation?: string }>;
|
||||
expect(store["group:123@g.us"]?.groupActivation).toBe("always");
|
||||
expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -244,6 +245,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+999",
|
||||
},
|
||||
{},
|
||||
@@ -270,6 +272,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
GroupSubject: "Test Group",
|
||||
GroupMembers: "Alice (+1), Bob (+2)",
|
||||
|
||||
@@ -29,7 +29,9 @@ import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGERS,
|
||||
buildGroupDisplayName,
|
||||
loadSessionStore,
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveStorePath,
|
||||
@@ -364,9 +366,9 @@ export async function getReplyFromConfig(
|
||||
let persistedModelOverride: string | undefined;
|
||||
let persistedProviderOverride: string | undefined;
|
||||
|
||||
const groupResolution = resolveGroupSessionKey(ctx);
|
||||
const isGroup =
|
||||
typeof ctx.From === "string" &&
|
||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
@@ -399,6 +401,16 @@ export async function getReplyFromConfig(
|
||||
|
||||
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
||||
sessionStore = loadSessionStore(storePath);
|
||||
if (
|
||||
groupResolution?.legacyKey &&
|
||||
groupResolution.legacyKey !== sessionKey
|
||||
) {
|
||||
const legacyEntry = sessionStore[groupResolution.legacyKey];
|
||||
if (legacyEntry && !sessionStore[sessionKey]) {
|
||||
sessionStore[sessionKey] = legacyEntry;
|
||||
delete sessionStore[groupResolution.legacyKey];
|
||||
}
|
||||
}
|
||||
const entry = sessionStore[sessionKey];
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
@@ -431,7 +443,35 @@ export async function getReplyFromConfig(
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
queueMode: baseEntry?.queueMode,
|
||||
displayName: baseEntry?.displayName,
|
||||
chatType: baseEntry?.chatType,
|
||||
surface: baseEntry?.surface,
|
||||
subject: baseEntry?.subject,
|
||||
room: baseEntry?.room,
|
||||
space: baseEntry?.space,
|
||||
};
|
||||
if (groupResolution?.surface) {
|
||||
const surface = groupResolution.surface;
|
||||
const subject = ctx.GroupSubject?.trim();
|
||||
const isRoomSurface = surface === "discord" || surface === "slack";
|
||||
const nextRoom =
|
||||
isRoomSurface && subject && subject.startsWith("#") ? subject : undefined;
|
||||
const nextSubject = nextRoom ? undefined : subject;
|
||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||
sessionEntry.surface = surface;
|
||||
if (nextSubject) sessionEntry.subject = nextSubject;
|
||||
if (nextRoom) sessionEntry.room = nextRoom;
|
||||
sessionEntry.displayName = buildGroupDisplayName({
|
||||
surface: sessionEntry.surface,
|
||||
subject: sessionEntry.subject,
|
||||
room: sessionEntry.room,
|
||||
space: sessionEntry.space,
|
||||
id: groupResolution.id,
|
||||
key: sessionKey,
|
||||
});
|
||||
} else if (!sessionEntry.chatType) {
|
||||
sessionEntry.chatType = "direct";
|
||||
}
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
|
||||
@@ -1038,8 +1078,7 @@ export async function getReplyFromConfig(
|
||||
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
|
||||
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
|
||||
const isGroupSession =
|
||||
typeof ctx.From === "string" &&
|
||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||
sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
|
||||
const isMainSession =
|
||||
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
||||
if (isMainSession) {
|
||||
|
||||
@@ -63,8 +63,9 @@ describe("buildStatusMessage", () => {
|
||||
sessionId: "g1",
|
||||
updatedAt: 0,
|
||||
groupActivation: "always",
|
||||
chatType: "group",
|
||||
},
|
||||
sessionKey: "group:123@g.us",
|
||||
sessionKey: "whatsapp:group:123@g.us",
|
||||
sessionScope: "per-sender",
|
||||
webLinked: true,
|
||||
});
|
||||
|
||||
@@ -191,7 +191,13 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
|
||||
const groupActivationLine = args.sessionKey?.startsWith("group:")
|
||||
const isGroupSession =
|
||||
entry?.chatType === "group" ||
|
||||
entry?.chatType === "room" ||
|
||||
Boolean(args.sessionKey?.includes(":group:")) ||
|
||||
Boolean(args.sessionKey?.includes(":channel:")) ||
|
||||
Boolean(args.sessionKey?.startsWith("group:"));
|
||||
const groupActivationLine = isGroupSession
|
||||
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -470,7 +470,7 @@ export async function agentCommand(
|
||||
}
|
||||
if (deliveryProvider === "signal" && !signalTarget) {
|
||||
const err = new Error(
|
||||
"Delivering to Signal requires --to <E.164|group:ID|signal:+E.164>",
|
||||
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||
);
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("sessionsCommand", () => {
|
||||
|
||||
it("shows placeholder rows when tokens are missing", async () => {
|
||||
const store = writeStore({
|
||||
"group:demo": {
|
||||
"discord:group:demo": {
|
||||
sessionId: "xyz",
|
||||
updatedAt: Date.now() - 5 * 60_000,
|
||||
thinkingLevel: "high",
|
||||
@@ -89,7 +89,7 @@ describe("sessionsCommand", () => {
|
||||
|
||||
fs.rmSync(store);
|
||||
|
||||
const row = logs.find((line) => line.includes("group:demo")) ?? "";
|
||||
const row = logs.find((line) => line.includes("discord:group:demo")) ?? "";
|
||||
expect(row).toContain("-".padEnd(20));
|
||||
expect(row).toContain("think:high");
|
||||
expect(row).toContain("5m ago");
|
||||
|
||||
@@ -119,10 +119,17 @@ const formatAge = (ms: number | null | undefined) => {
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
function classifyKey(key: string): SessionRow["kind"] {
|
||||
function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
|
||||
if (key === "global") return "global";
|
||||
if (key.startsWith("group:")) return "group";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
}
|
||||
|
||||
@@ -132,7 +139,7 @@ function toRows(store: Record<string, SessionEntry>): SessionRow[] {
|
||||
const updatedAt = entry?.updatedAt ?? null;
|
||||
return {
|
||||
key,
|
||||
kind: classifyKey(key),
|
||||
kind: classifyKey(key, entry),
|
||||
updatedAt,
|
||||
ageMs: updatedAt ? Date.now() - updatedAt : null,
|
||||
sessionId: entry?.sessionId,
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
||||
|
||||
return {
|
||||
key,
|
||||
kind: classifyKey(key),
|
||||
kind: classifyKey(key, entry),
|
||||
sessionId: entry?.sessionId,
|
||||
updatedAt,
|
||||
age,
|
||||
@@ -169,10 +169,17 @@ const formatContextUsage = (
|
||||
return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`;
|
||||
};
|
||||
|
||||
const classifyKey = (key: string): SessionStatus["kind"] => {
|
||||
const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => {
|
||||
if (key === "global") return "global";
|
||||
if (key.startsWith("group:")) return "group";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,26 @@ describe("sessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes group keys with surface when available", () => {
|
||||
expect(
|
||||
deriveSessionKey("per-sender", {
|
||||
From: "12345-678@g.us",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
}),
|
||||
).toBe("whatsapp:group:12345-678@g.us");
|
||||
});
|
||||
|
||||
it("keeps explicit surface when provided in group key", () => {
|
||||
expect(
|
||||
resolveSessionKey(
|
||||
"per-sender",
|
||||
{ From: "group:discord:12345", ChatType: "group" },
|
||||
"main",
|
||||
),
|
||||
).toBe("discord:group:12345");
|
||||
});
|
||||
|
||||
it("collapses direct chats to main by default", () => {
|
||||
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
||||
});
|
||||
|
||||
@@ -10,11 +10,24 @@ import { normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
const GROUP_SURFACES = new Set([
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"signal",
|
||||
"imessage",
|
||||
"webchat",
|
||||
"slack",
|
||||
]);
|
||||
|
||||
export type SessionChatType = "direct" | "group" | "room";
|
||||
|
||||
export type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
chatType?: SessionChatType;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
providerOverride?: string;
|
||||
@@ -27,6 +40,11 @@ export type SessionEntry = {
|
||||
totalTokens?: number;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
lastChannel?:
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
@@ -38,6 +56,14 @@ export type SessionEntry = {
|
||||
skillsSnapshot?: SessionSkillSnapshot;
|
||||
};
|
||||
|
||||
export type GroupKeyResolution = {
|
||||
key: string;
|
||||
legacyKey?: string;
|
||||
surface?: string;
|
||||
id?: string;
|
||||
chatType?: SessionChatType;
|
||||
};
|
||||
|
||||
export type SessionSkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
@@ -66,6 +92,135 @@ export function resolveStorePath(store?: string) {
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
function normalizeGroupLabel(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
const dashed = trimmed.replace(/\s+/g, "-");
|
||||
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
|
||||
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||
}
|
||||
|
||||
function shortenGroupId(value?: string) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length <= 14) return trimmed;
|
||||
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function buildGroupDisplayName(params: {
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
id?: string;
|
||||
key: string;
|
||||
}) {
|
||||
const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim();
|
||||
const detail =
|
||||
params.room?.trim() ||
|
||||
params.subject?.trim() ||
|
||||
params.space?.trim() ||
|
||||
"";
|
||||
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
|
||||
const rawLabel = detail || fallbackId;
|
||||
let token = normalizeGroupLabel(rawLabel);
|
||||
if (!token) {
|
||||
token = normalizeGroupLabel(shortenGroupId(rawLabel));
|
||||
}
|
||||
if (!params.room && token.startsWith("#")) {
|
||||
token = token.replace(/^#+/, "");
|
||||
}
|
||||
if (token && !/^[@#]/.test(token) && !token.startsWith("g-")) {
|
||||
token = `g-${token}`;
|
||||
}
|
||||
return token ? `${surfaceKey}:${token}` : surfaceKey;
|
||||
}
|
||||
|
||||
export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null {
|
||||
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
|
||||
if (!from) return null;
|
||||
const chatType = ctx.ChatType?.trim().toLowerCase();
|
||||
const isGroup =
|
||||
chatType === "group" ||
|
||||
from.startsWith("group:") ||
|
||||
from.includes("@g.us") ||
|
||||
from.includes(":group:") ||
|
||||
from.includes(":channel:");
|
||||
if (!isGroup) return null;
|
||||
|
||||
const surfaceHint = ctx.Surface?.trim().toLowerCase();
|
||||
const hasLegacyGroupPrefix = from.startsWith("group:");
|
||||
const raw = (hasLegacyGroupPrefix ? from.slice("group:".length) : from).trim();
|
||||
|
||||
let surface: string | undefined;
|
||||
let kind: "group" | "channel" | undefined;
|
||||
let id = "";
|
||||
|
||||
const parseKind = (value: string) => {
|
||||
if (value === "channel") return "channel";
|
||||
return "group";
|
||||
};
|
||||
|
||||
const parseParts = (parts: string[]) => {
|
||||
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||
surface = parts[0];
|
||||
if (parts.length >= 3) {
|
||||
const kindCandidate = parts[1];
|
||||
if (["group", "channel"].includes(kindCandidate)) {
|
||||
kind = parseKind(kindCandidate);
|
||||
id = parts.slice(2).join(":");
|
||||
} else {
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
} else {
|
||||
id = parts[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
|
||||
kind = parseKind(parts[0]);
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
};
|
||||
|
||||
if (hasLegacyGroupPrefix) {
|
||||
const legacyParts = raw.split(":").filter(Boolean);
|
||||
if (legacyParts.length > 1) {
|
||||
parseParts(legacyParts);
|
||||
} else {
|
||||
id = raw;
|
||||
}
|
||||
} else if (from.includes("@g.us") && !from.includes(":")) {
|
||||
id = from;
|
||||
} else {
|
||||
parseParts(from.split(":").filter(Boolean));
|
||||
if (!id) {
|
||||
id = raw || from;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSurface = surface ?? surfaceHint;
|
||||
if (!resolvedSurface) {
|
||||
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
||||
return { key: legacy, id: raw || from, legacyKey: legacy, chatType: "group" };
|
||||
}
|
||||
|
||||
const resolvedKind = kind === "channel" ? "channel" : "group";
|
||||
const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`;
|
||||
let legacyKey: string | undefined;
|
||||
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
||||
legacyKey = `group:${id || raw || from}`;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
legacyKey,
|
||||
surface: resolvedSurface,
|
||||
id: id || raw || from,
|
||||
chatType: resolvedKind === "channel" ? "room" : "group",
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
): Record<string, SessionEntry> {
|
||||
@@ -145,6 +300,12 @@ export async function updateLastRoute(params: {
|
||||
totalTokens: existing?.totalTokens,
|
||||
model: existing?.model,
|
||||
contextTokens: existing?.contextTokens,
|
||||
displayName: existing?.displayName,
|
||||
chatType: existing?.chatType,
|
||||
surface: existing?.surface,
|
||||
subject: existing?.subject,
|
||||
room: existing?.room,
|
||||
space: existing?.space,
|
||||
skillsSnapshot: existing?.skillsSnapshot,
|
||||
lastChannel: channel,
|
||||
lastTo: to?.trim() ? to.trim() : undefined,
|
||||
@@ -157,14 +318,9 @@ export async function updateLastRoute(params: {
|
||||
// Decide which session bucket to use (per-sender vs global).
|
||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||
if (scope === "global") return "global";
|
||||
const resolvedGroup = resolveGroupSessionKey(ctx);
|
||||
if (resolvedGroup) return resolvedGroup.key;
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
// Preserve group conversations as distinct buckets
|
||||
if (typeof ctx.From === "string" && ctx.From.includes("@g.us")) {
|
||||
return `group:${ctx.From}`;
|
||||
}
|
||||
if (typeof ctx.From === "string" && ctx.From.startsWith("group:")) {
|
||||
return ctx.From;
|
||||
}
|
||||
return from || "unknown";
|
||||
}
|
||||
|
||||
@@ -181,7 +337,10 @@ export function resolveSessionKey(
|
||||
if (scope === "global") return raw;
|
||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||
const canonical = (mainKey ?? "main").trim() || "main";
|
||||
const isGroup = raw.startsWith("group:") || raw.includes("@g.us");
|
||||
const isGroup =
|
||||
raw.startsWith("group:") ||
|
||||
raw.includes(":group:") ||
|
||||
raw.includes(":channel:");
|
||||
if (!isGroup) return canonical;
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ChannelType,
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
@@ -106,7 +107,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
if (message.author?.bot) return;
|
||||
if (!message.author) return;
|
||||
|
||||
const isDirectMessage = !message.guild;
|
||||
const channelType = message.channel.type;
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const isDirectMessage = channelType === ChannelType.DM;
|
||||
const isGuildMessage = Boolean(message.guild);
|
||||
const botId = client.user?.id;
|
||||
const wasMentioned =
|
||||
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
||||
@@ -142,7 +146,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDirectMessage && guildAllowFrom) {
|
||||
if (!isDirectMessage && isGuildMessage && guildAllowFrom) {
|
||||
const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [
|
||||
"guild:",
|
||||
]);
|
||||
@@ -197,7 +201,16 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
const fromLabel = isDirectMessage
|
||||
? buildDirectLabel(message)
|
||||
: buildGuildLabel(message);
|
||||
: isGroupDm
|
||||
? buildGroupDmLabel(message)
|
||||
: buildGuildLabel(message);
|
||||
const groupSubject = (() => {
|
||||
if (isDirectMessage) return undefined;
|
||||
const channelName =
|
||||
"name" in message.channel ? message.channel.name : message.channelId;
|
||||
if (!channelName) return undefined;
|
||||
return isGuildMessage ? `#${channelName}` : channelName;
|
||||
})();
|
||||
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
|
||||
let combinedBody = formatAgentEnvelope({
|
||||
surface: "Discord",
|
||||
@@ -238,10 +251,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
: `channel:${message.channelId}`,
|
||||
ChatType: isDirectMessage ? "direct" : "group",
|
||||
SenderName: message.member?.displayName ?? message.author.tag,
|
||||
GroupSubject:
|
||||
!isDirectMessage && "name" in message.channel
|
||||
? message.channel.name
|
||||
: undefined,
|
||||
GroupSubject: groupSubject,
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: wasMentioned,
|
||||
MessageSid: message.id,
|
||||
@@ -358,6 +368,13 @@ function buildDirectLabel(message: import("discord.js").Message) {
|
||||
return `${username} id:${message.author.id}`;
|
||||
}
|
||||
|
||||
function buildGroupDmLabel(message: import("discord.js").Message) {
|
||||
const channelName =
|
||||
"name" in message.channel ? message.channel.name : undefined;
|
||||
const name = channelName ? ` ${channelName}` : "";
|
||||
return `Group DM${name} id:${message.channelId}`;
|
||||
}
|
||||
|
||||
function buildGuildLabel(message: import("discord.js").Message) {
|
||||
const channelName =
|
||||
"name" in message.channel ? message.channel.name : message.channelId;
|
||||
|
||||
@@ -3865,7 +3865,7 @@ describe("gateway server", () => {
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
},
|
||||
"group:dev": {
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-group",
|
||||
updatedAt: now - 120_000,
|
||||
totalTokens: 50,
|
||||
@@ -3977,7 +3977,7 @@ describe("gateway server", () => {
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
||||
ws,
|
||||
"sessions.delete",
|
||||
{ key: "group:dev" },
|
||||
{ key: "discord:group:dev" },
|
||||
);
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
@@ -3986,7 +3986,9 @@ describe("gateway server", () => {
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(listAfterDelete.ok).toBe(true);
|
||||
expect(
|
||||
listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"),
|
||||
listAfterDelete.payload?.sessions.some(
|
||||
(s) => s.key === "discord:group:dev",
|
||||
),
|
||||
).toBe(false);
|
||||
const filesAfterDelete = await fs.readdir(dir);
|
||||
expect(
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
@@ -455,6 +456,11 @@ type GatewaySessionsDefaults = {
|
||||
type GatewaySessionRow = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
updatedAt: number | null;
|
||||
sessionId?: string;
|
||||
systemSent?: boolean;
|
||||
@@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) {
|
||||
return { cfg, storePath, store, entry };
|
||||
}
|
||||
|
||||
function classifySessionKey(key: string): GatewaySessionRow["kind"] {
|
||||
function classifySessionKey(
|
||||
key: string,
|
||||
entry?: SessionEntry,
|
||||
): GatewaySessionRow["kind"] {
|
||||
if (key === "global") return "global";
|
||||
if (key.startsWith("group:")) return "group";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
}
|
||||
|
||||
function parseGroupKey(
|
||||
key: string,
|
||||
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
|
||||
if (key.startsWith("group:")) {
|
||||
const raw = key.slice("group:".length);
|
||||
return raw ? { id: raw } : null;
|
||||
}
|
||||
const parts = key.split(":").filter(Boolean);
|
||||
if (parts.length >= 3) {
|
||||
const [surface, kind, ...rest] = parts;
|
||||
if (kind === "group" || kind === "channel") {
|
||||
const id = rest.join(":");
|
||||
return { surface, kind, id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
@@ -913,9 +947,32 @@ function listSessionsFromStore(params: {
|
||||
const input = entry?.inputTokens ?? 0;
|
||||
const output = entry?.outputTokens ?? 0;
|
||||
const total = entry?.totalTokens ?? input + output;
|
||||
const parsed = parseGroupKey(key);
|
||||
const surface = entry?.surface ?? parsed?.surface;
|
||||
const subject = entry?.subject;
|
||||
const room = entry?.room;
|
||||
const space = entry?.space;
|
||||
const id = parsed?.id;
|
||||
const displayName =
|
||||
entry?.displayName ??
|
||||
(surface
|
||||
? buildGroupDisplayName({
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
id,
|
||||
key,
|
||||
})
|
||||
: undefined);
|
||||
return {
|
||||
key,
|
||||
kind: classifySessionKey(key),
|
||||
kind: classifySessionKey(key, entry),
|
||||
displayName,
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
updatedAt,
|
||||
sessionId: entry?.sessionId,
|
||||
systemSent: entry?.systemSent,
|
||||
@@ -2881,6 +2938,12 @@ export async function startGatewayServer(
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
displayName: entry?.displayName,
|
||||
chatType: entry?.chatType,
|
||||
surface: entry?.surface,
|
||||
subject: entry?.subject,
|
||||
room: entry?.room,
|
||||
space: entry?.space,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
|
||||
@@ -52,6 +52,23 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
||||
if (!trimmed) throw new Error("iMessage target is required");
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
for (const { prefix, service } of SERVICE_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const remainder = stripPrefix(trimmed, prefix);
|
||||
if (!remainder) throw new Error(`${prefix} target is required`);
|
||||
const remainderLower = remainder.toLowerCase();
|
||||
const isChatTarget =
|
||||
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||
remainderLower.startsWith("group:");
|
||||
if (isChatTarget) {
|
||||
return parseIMessageTarget(remainder);
|
||||
}
|
||||
return { kind: "handle", to: remainder, service };
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of CHAT_ID_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(trimmed, prefix);
|
||||
@@ -89,14 +106,6 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
||||
return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
|
||||
for (const { prefix, service } of SERVICE_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const to = stripPrefix(trimmed, prefix);
|
||||
if (!to) throw new Error(`${prefix} target is required`);
|
||||
return { kind: "handle", to, service };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "handle", to: trimmed, service: "auto" };
|
||||
}
|
||||
|
||||
@@ -105,6 +114,14 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
||||
if (!trimmed) return { kind: "handle", handle: "" };
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
for (const { prefix } of SERVICE_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const remainder = stripPrefix(trimmed, prefix);
|
||||
if (!remainder) return { kind: "handle", handle: "" };
|
||||
return parseIMessageAllowTarget(remainder);
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of CHAT_ID_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(trimmed, prefix);
|
||||
|
||||
@@ -43,19 +43,20 @@ function parseTarget(raw: string): SignalTarget {
|
||||
let value = raw.trim();
|
||||
if (!value) throw new Error("Signal recipient is required");
|
||||
const lower = value.toLowerCase();
|
||||
if (lower.startsWith("group:")) {
|
||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
||||
}
|
||||
if (lower.startsWith("signal:")) {
|
||||
value = value.slice("signal:".length).trim();
|
||||
}
|
||||
if (lower.startsWith("username:")) {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.startsWith("group:")) {
|
||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
||||
}
|
||||
if (normalized.startsWith("username:")) {
|
||||
return {
|
||||
type: "username",
|
||||
username: value.slice("username:".length).trim(),
|
||||
};
|
||||
}
|
||||
if (lower.startsWith("u:")) {
|
||||
if (normalized.startsWith("u:")) {
|
||||
return { type: "username", username: value.trim() };
|
||||
}
|
||||
return { type: "recipient", recipient: value };
|
||||
|
||||
@@ -36,7 +36,7 @@ function normalizeChatId(to: string): string {
|
||||
|
||||
// Common internal prefixes that sometimes leak into outbound sends.
|
||||
// - ctx.To uses `telegram:<id>`
|
||||
// - group sessions often use `group:<id>`
|
||||
// - group sessions often use `telegram:group:<id>`
|
||||
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
|
||||
// Accept t.me links for public chats/channels.
|
||||
|
||||
@@ -1015,7 +1015,7 @@ describe("web auto-reply", () => {
|
||||
.mockResolvedValueOnce({ text: "ok" });
|
||||
|
||||
const { storePath, cleanup } = await makeSessionStore({
|
||||
"group:123@g.us": {
|
||||
"whatsapp:group:123@g.us": {
|
||||
sessionId: "g-1",
|
||||
updatedAt: Date.now(),
|
||||
groupActivation: "always",
|
||||
|
||||
@@ -412,7 +412,10 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const isGroupKey = (key: string) =>
|
||||
key.startsWith("group:") || key.includes("@g.us");
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:") ||
|
||||
key.includes("@g.us");
|
||||
const isCronKey = (key: string) => key.startsWith("cron:");
|
||||
|
||||
const recipients = Object.entries(store)
|
||||
@@ -812,7 +815,7 @@ export async function monitorWebProvider(
|
||||
const resolveGroupActivationFor = (conversationId: string) => {
|
||||
const key = conversationId.startsWith("group:")
|
||||
? conversationId
|
||||
: `group:${conversationId}`;
|
||||
: `whatsapp:group:${conversationId}`;
|
||||
const store = loadSessionStore(sessionStorePath);
|
||||
const entry = store[key];
|
||||
const requireMention = cfg.routing?.groupChat?.requireMention;
|
||||
|
||||
Reference in New Issue
Block a user