merge main into shadow/discord-id

This commit is contained in:
Peter Steinberger
2026-01-02 11:27:24 +01:00
63 changed files with 1434 additions and 262 deletions

View File

@@ -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)",

View File

@@ -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,41 @@ 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 space = ctx.GroupSpace?.trim();
const explicitRoom = ctx.GroupRoom?.trim();
const isRoomSurface = surface === "discord" || surface === "slack";
const nextRoom =
explicitRoom ??
(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;
if (space) sessionEntry.space = space;
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 +1084,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) {

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -12,6 +12,8 @@ export type MsgContext = {
Transcript?: string;
ChatType?: string;
GroupSubject?: string;
GroupRoom?: string;
GroupSpace?: string;
GroupMembers?: string;
SenderName?: string;
SenderE164?: string;

View File

@@ -1 +1 @@
13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36
988ec7bedb11cab74f82faf4475df758e6f07866b69949ffc2cce89cb3d8265b

View File

@@ -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);

View File

@@ -39,6 +39,7 @@ import {
printWizardHeader,
probeGatewayReachable,
randomToken,
resolveControlUiLinks,
summarizeExistingConfig,
} from "./onboard-helpers.js";
import { setupProviders } from "./onboard-providers.js";
@@ -550,6 +551,30 @@ export async function runConfigureWizard(
}
}
note(
(() => {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({ bind, port: gatewayPort });
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
"\n",
);
})(),
"Control UI",
);
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: false,
}),
runtime,
);
if (wantsOpen) {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({ bind, port: gatewayPort });
await openUrl(links.httpUrl);
}
outro("Configure complete.");
}

View File

@@ -12,6 +12,7 @@ import type { ClawdisConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDIS } from "../config/config.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
@@ -205,3 +206,20 @@ function summarizeError(err: unknown): string {
}
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
export function resolveControlUiLinks(params: {
port: number;
bind?: "auto" | "lan" | "tailnet" | "loopback";
}): { httpUrl: string; wsUrl: string } {
const port = params.port;
const bind = params.bind ?? "loopback";
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const host =
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
? (tailnetIPv4 ?? "127.0.0.1")
: "127.0.0.1";
return {
httpUrl: `http://${host}:${port}/`,
wsUrl: `ws://${host}:${port}`,
};
}

View File

@@ -20,7 +20,6 @@ import {
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -40,6 +39,7 @@ import {
printWizardHeader,
probeGatewayReachable,
randomToken,
resolveControlUiLinks,
summarizeExistingConfig,
} from "./onboard-helpers.js";
import { setupProviders } from "./onboard-providers.js";
@@ -481,18 +481,25 @@ export async function runInteractiveOnboarding(
note(
(() => {
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const host =
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
? (tailnetIPv4 ?? "127.0.0.1")
: "127.0.0.1";
return [
`Control UI: http://${host}:${port}/`,
`Gateway WS: ws://${host}:${port}`,
].join("\n");
const links = resolveControlUiLinks({ bind, port });
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
"\n",
);
})(),
"Open the Control UI",
"Control UI",
);
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: true,
}),
runtime,
);
if (wantsOpen) {
const links = resolveControlUiLinks({ bind, port });
await openUrl(links.httpUrl);
}
outro("Onboarding complete.");
}

View File

@@ -64,6 +64,93 @@ function noteDiscordTokenHelp(): void {
);
}
function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
return {
...cfg,
routing: {
...(cfg.routing ?? {}),
allowFrom,
},
};
}
async function promptWhatsAppAllowFrom(
cfg: ClawdisConfig,
runtime: RuntimeEnv,
): Promise<ClawdisConfig> {
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
note(
[
"WhatsApp direct chats are gated by `routing.allowFrom`.",
'Default (unset) = self-chat only; use "*" to allow anyone.',
`Current: ${existingLabel}`,
].join("\n"),
"WhatsApp allowlist",
);
const options =
existingAllowFrom.length > 0
? ([
{ value: "keep", label: "Keep current" },
{ value: "self", label: "Self-chat only (unset)" },
{ value: "list", label: "Specific numbers (recommended)" },
{ value: "any", label: "Anyone (*)" },
] as const)
: ([
{ value: "self", label: "Self-chat only (default)" },
{ value: "list", label: "Specific numbers (recommended)" },
{ value: "any", label: "Anyone (*)" },
] as const);
const mode = guardCancel(
await select({
message: "Who can trigger the bot via WhatsApp?",
options: options.map((opt) => ({ value: opt.value, label: opt.label })),
}),
runtime,
) as (typeof options)[number]["value"];
if (mode === "keep") return cfg;
if (mode === "self") return setRoutingAllowFrom(cfg, undefined);
if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]);
const allowRaw = guardCancel(
await text({
message: "Allowed sender numbers (comma-separated, E.164)",
placeholder: "+15555550123, +447700900123",
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = raw
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) return "Required";
for (const part of parts) {
if (part === "*") continue;
const normalized = normalizeE164(part);
if (!normalized) return `Invalid number: ${part}`;
}
return undefined;
},
}),
runtime,
);
const parts = String(allowRaw)
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
const normalized = parts.map((part) =>
part === "*" ? "*" : normalizeE164(part),
);
const unique = [...new Set(normalized.filter(Boolean))];
return setRoutingAllowFrom(cfg, unique);
}
export async function setupProviders(
cfg: ClawdisConfig,
runtime: RuntimeEnv,
@@ -198,70 +285,7 @@ export async function setupProviders(
note("Run `clawdis login` later to link WhatsApp.", "WhatsApp");
}
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
if (existingAllowFrom.length === 0) {
note(
[
"WhatsApp direct chats are gated by `routing.allowFrom`.",
'Default (unset) = self-chat only; use "*" to allow anyone.',
].join("\n"),
"Allowlist (recommended)",
);
const mode = guardCancel(
await select({
message: "Who can trigger the bot via WhatsApp?",
options: [
{ value: "self", label: "Self-chat only (default)" },
{ value: "list", label: "Specific numbers (recommended)" },
{ value: "any", label: "Anyone (*)" },
],
}),
runtime,
) as "self" | "list" | "any";
if (mode === "any") {
next = {
...next,
routing: { ...next.routing, allowFrom: ["*"] },
};
} else if (mode === "list") {
const allowRaw = guardCancel(
await text({
message: "Allowed sender numbers (comma-separated, E.164)",
placeholder: "+15555550123, +447700900123",
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = raw
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) return "Required";
for (const part of parts) {
if (part === "*") continue;
const normalized = normalizeE164(part);
if (!normalized) return `Invalid number: ${part}`;
}
return undefined;
},
}),
runtime,
);
const parts = String(allowRaw)
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
const normalized = parts.map((part) =>
part === "*" ? "*" : normalizeE164(part),
);
const unique = [...new Set(normalized.filter(Boolean))];
next = {
...next,
routing: { ...next.routing, allowFrom: unique },
};
}
}
next = await promptWhatsAppAllowFrom(next, runtime);
}
if (selection.includes("telegram")) {

View File

@@ -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");

View File

@@ -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,

View File

@@ -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";
};

View File

@@ -206,6 +206,65 @@ describe("config identity defaults", () => {
});
});
import fs from "node:fs/promises";
describe("config discord", () => {
let previousHome: string | undefined;
beforeEach(() => {
previousHome = process.env.HOME;
});
afterEach(() => {
process.env.HOME = previousHome;
});
it("loads discord guild map + dm group settings", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify(
{
discord: {
enabled: true,
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
},
},
},
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.discord?.enabled).toBe(true);
expect(cfg.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
expect(cfg.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
expect(cfg.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
});
});
});
describe("Nix integration (U3, U5, U9)", () => {
describe("U3: isNixMode env var detection", () => {
it("isNixMode is false when CLAWDIS_NIX_MODE is not set", async () => {

View File

@@ -164,21 +164,40 @@ export type TelegramConfig = {
webhookPath?: string;
};
export type DiscordDmConfig = {
/** If false, ignore all incoming Discord DMs. Default: true. */
enabled?: boolean;
/** Allowlist for DM senders (ids or names). */
allowFrom?: Array<string | number>;
/** If true, allow group DMs (default: false). */
groupEnabled?: boolean;
/** Optional allowlist for group DM channels (ids or slugs). */
groupChannels?: Array<string | number>;
};
export type DiscordGuildChannelConfig = {
allow?: boolean;
requireMention?: boolean;
};
export type DiscordGuildEntry = {
slug?: string;
requireMention?: boolean;
users?: Array<string | number>;
channels?: Record<string, DiscordGuildChannelConfig>;
};
export type DiscordConfig = {
/** If false, do not start the Discord provider. Default: true. */
enabled?: boolean;
token?: string;
allowFrom?: Array<string | number>;
guildAllowFrom?: {
guilds?: Array<string | number>;
users?: Array<string | number>;
};
requireMention?: boolean;
mediaMaxMb?: number;
/** Number of recent guild messages to include for context (default: 20). */
historyLimit?: number;
/** Allow agent-triggered Discord reactions (default: true). */
enableReactions?: boolean;
dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>;
};
export type SignalConfig = {
@@ -908,17 +927,53 @@ const ClawdisSchema = z.object({
.object({
enabled: z.boolean().optional(),
token: z.string().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
guildAllowFrom: z
.object({
guilds: z.array(z.union([z.string(), z.number()])).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional(),
requireMention: z.boolean().optional(),
mediaMaxMb: z.number().positive().optional(),
historyLimit: z.number().int().min(0).optional(),
enableReactions: z.boolean().optional(),
dm: z
.object({
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional(),
guilds: z
.record(
z.string(),
z
.object({
slug: z.string().optional(),
requireMention: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
channels: z
.record(
z.string(),
z
.object({
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
})
.optional(),
)
.optional(),
guild: z
.object({
allowFrom: z
.object({
guilds: z.array(z.union([z.string(), z.number()])).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional(),
channels: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional(),
historyLimit: z.number().int().min(0).optional(),
})
.optional(),
})
.optional(),
signal: z

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildGroupDisplayName,
deriveSessionKey,
loadSessionStore,
resolveSessionKey,
@@ -31,6 +32,38 @@ 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("builds discord display name with guild+channel slugs", () => {
expect(
buildGroupDisplayName({
surface: "discord",
room: "#general",
space: "friends-of-clawd",
id: "123",
key: "discord:group:123",
}),
).toBe("discord:friends-of-clawd#general");
});
it("collapses direct chats to main by default", () => {
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
});

View File

@@ -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,142 @@ 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 room = params.room?.trim();
const space = params.space?.trim();
const subject = params.subject?.trim();
const detail =
(room && space
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
: room || subject || space || "") || "";
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.includes("#")
) {
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 +307,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 +325,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 +344,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;
}

149
src/discord/monitor.test.ts Normal file
View File

@@ -0,0 +1,149 @@
import {
allowListMatches,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfig,
resolveDiscordGuildEntry,
resolveGroupDmAllow,
type DiscordGuildEntryResolved,
} from "./monitor.js";
const fakeGuild = (id: string, name: string) =>
({ id, name } as unknown as import("discord.js").Guild);
const makeEntries = (
entries: Record<string, Partial<DiscordGuildEntryResolved>>,
): Record<string, DiscordGuildEntryResolved> => {
const out: Record<string, DiscordGuildEntryResolved> = {};
for (const [key, value] of Object.entries(entries)) {
out[key] = {
slug: value.slug,
requireMention: value.requireMention,
users: value.users,
channels: value.channels,
};
}
return out;
};
describe("discord allowlist helpers", () => {
it("normalizes slugs", () => {
expect(normalizeDiscordSlug("Friends of Clawd"))
.toBe("friends-of-clawd");
expect(normalizeDiscordSlug("#General"))
.toBe("general");
expect(normalizeDiscordSlug("Dev__Chat"))
.toBe("dev-chat");
});
it("matches ids or names", () => {
const allow = normalizeDiscordAllowList(
["123", "steipete", "Friends of Clawd"],
["discord:", "user:", "guild:", "channel:"],
);
expect(allow).not.toBeNull();
expect(allowListMatches(allow!, { id: "123" })).toBe(true);
expect(allowListMatches(allow!, { name: "steipete" })).toBe(true);
expect(allowListMatches(allow!, { name: "friends-of-clawd" })).toBe(true);
expect(allowListMatches(allow!, { name: "other" })).toBe(false);
});
});
describe("discord guild/channel resolution", () => {
it("resolves guild entry by id", () => {
const guildEntries = makeEntries({
"123": { slug: "friends-of-clawd" },
});
const resolved = resolveDiscordGuildEntry({
guild: fakeGuild("123", "Friends of Clawd"),
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.slug).toBe("friends-of-clawd");
});
it("resolves guild entry by slug key", () => {
const guildEntries = makeEntries({
"friends-of-clawd": { slug: "friends-of-clawd" },
});
const resolved = resolveDiscordGuildEntry({
guild: fakeGuild("123", "Friends of Clawd"),
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.slug).toBe("friends-of-clawd");
});
it("resolves channel config by slug", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
help: { allow: true, requireMention: true },
},
};
const channel = resolveDiscordChannelConfig({
guildInfo,
channelId: "456",
channelName: "General",
channelSlug: "general",
});
expect(channel?.allowed).toBe(true);
expect(channel?.requireMention).toBeUndefined();
const help = resolveDiscordChannelConfig({
guildInfo,
channelId: "789",
channelName: "Help",
channelSlug: "help",
});
expect(help?.allowed).toBe(true);
expect(help?.requireMention).toBe(true);
});
it("denies channel when config present but no match", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
},
};
const channel = resolveDiscordChannelConfig({
guildInfo,
channelId: "999",
channelName: "random",
channelSlug: "random",
});
expect(channel?.allowed).toBe(false);
});
});
describe("discord group DM gating", () => {
it("allows all when no allowlist", () => {
expect(
resolveGroupDmAllow({
channels: undefined,
channelId: "1",
channelName: "dm",
channelSlug: "dm",
}),
).toBe(true);
});
it("matches group DM allowlist", () => {
expect(
resolveGroupDmAllow({
channels: ["clawd-dm"],
channelId: "1",
channelName: "Clawd DM",
channelSlug: "clawd-dm",
}),
).toBe(true);
expect(
resolveGroupDmAllow({
channels: ["clawd-dm"],
channelId: "1",
channelName: "Other",
channelSlug: "other",
}),
).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import {
ChannelType,
Client,
Events,
GatewayIntentBits,
@@ -24,12 +25,6 @@ export type MonitorDiscordOpts = {
token?: string;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
allowFrom?: Array<string | number>;
guildAllowFrom?: {
guilds?: Array<string | number>;
users?: Array<string | number>;
};
requireMention?: boolean;
mediaMaxMb?: number;
historyLimit?: number;
};
@@ -47,6 +42,25 @@ type DiscordHistoryEntry = {
messageId?: string;
};
export type DiscordAllowList = {
allowAll: boolean;
ids: Set<string>;
names: Set<string>;
};
export type DiscordGuildEntryResolved = {
id?: string;
slug?: string;
requireMention?: boolean;
users?: Array<string | number>;
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
};
export type DiscordChannelConfigResolved = {
allowed: boolean;
requireMention?: boolean;
};
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = loadConfig();
const token = normalizeDiscordToken(
@@ -69,16 +83,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
};
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom;
const requireMention =
opts.requireMention ?? cfg.discord?.requireMention ?? true;
const dmConfig = cfg.discord?.dm;
const guildEntries = cfg.discord?.guilds;
const allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const historyLimit = Math.max(
0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
);
const dmEnabled = dmConfig?.enabled ?? true;
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
const client = new Client({
intents: [
@@ -106,7 +122,12 @@ 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);
if (isGroupDm && !groupDmEnabled) return;
if (isDirectMessage && !dmEnabled) return;
const botId = client.user?.id;
const wasMentioned =
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
@@ -117,7 +138,59 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
message.embeds[0]?.description ||
"";
if (!isDirectMessage && historyLimit > 0 && baseText) {
const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({
guild: message.guild,
guildEntries,
})
: null;
if (
isGuildMessage &&
guildEntries &&
Object.keys(guildEntries).length > 0 &&
!guildInfo
) {
logVerbose(
`Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`,
);
return;
}
const channelName =
(isGuildMessage || isGroupDm) && "name" in message.channel
? message.channel.name
: undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const guildSlug =
guildInfo?.slug ||
(message.guild?.name ? normalizeDiscordSlug(message.guild.name) : "");
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
guildInfo,
channelId: message.channelId,
channelName,
channelSlug,
})
: null;
const groupDmAllowed =
isGroupDm &&
resolveGroupDmAllow({
channels: groupDmChannels,
channelId: message.channelId,
channelName,
channelSlug,
});
if (isGroupDm && !groupDmAllowed) return;
if (isGuildMessage && channelConfig?.allowed === false) {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
);
return;
}
if (isGuildMessage && historyLimit > 0 && baseText) {
const history = guildHistories.get(message.channelId) ?? [];
history.push({
sender: message.member?.displayName ?? message.author.tag,
@@ -129,7 +202,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
guildHistories.set(message.channelId, history);
}
if (!isDirectMessage && requireMention) {
const resolvedRequireMention =
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
if (isGuildMessage && resolvedRequireMention) {
if (botId && !wasMentioned) {
logger.info(
{
@@ -142,46 +217,45 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
}
if (!isDirectMessage && guildAllowFrom) {
const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [
"guild:",
]);
const users = normalizeDiscordAllowList(guildAllowFrom.users, [
"discord:",
"user:",
]);
if (guilds || users) {
const guildId = message.guild?.id ?? "";
const userId = message.author.id;
const guildOk =
!guilds || guilds.allowAll || (guildId && guilds.ids.has(guildId));
const userOk = !users || users.allowAll || users.ids.has(userId);
if (!guildOk || !userOk) {
if (isGuildMessage) {
const userAllow = guildInfo?.users;
if (Array.isArray(userAllow) && userAllow.length > 0) {
const users = normalizeDiscordAllowList(userAllow, [
"discord:",
"user:",
]);
const userOk =
!users ||
allowListMatches(users, {
id: message.author.id,
name: message.author.username,
tag: message.author.tag,
});
if (!userOk) {
logVerbose(
`Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`,
`Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`,
);
return;
}
}
}
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
const allowed = allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean);
const candidate = message.author.id;
const normalized = new Set(
allowed
.filter((entry) => entry !== "*")
.map((entry) => entry.replace(/^discord:/i, "")),
);
const allowList = normalizeDiscordAllowList(allowFrom, [
"discord:",
"user:",
]);
const permitted =
allowed.includes("*") ||
normalized.has(candidate) ||
allowed.includes(candidate);
allowList &&
allowListMatches(allowList, {
id: message.author.id,
name: message.author.username,
tag: message.author.tag,
});
if (!permitted) {
logVerbose(
`Blocked unauthorized discord sender ${candidate} (not in allowFrom)`,
`Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`,
);
return;
}
@@ -198,6 +272,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const fromLabel = isDirectMessage
? buildDirectLabel(message)
: buildGuildLabel(message);
const groupRoom =
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
let combinedBody = formatAgentEnvelope({
surface: "Discord",
@@ -240,10 +317,9 @@ 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,
GroupRoom: groupRoom,
GroupSpace: isGuildMessage ? guildSlug || undefined : undefined,
Surface: "discord" as const,
WasMentioned: wasMentioned,
MessageSid: message.id,
@@ -292,7 +368,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
token,
runtime,
});
if (!isDirectMessage && shouldClearHistory && historyLimit > 0) {
if (isGuildMessage && shouldClearHistory && historyLimit > 0) {
guildHistories.set(message.channelId, []);
}
} catch (err) {
@@ -366,25 +442,174 @@ function buildGuildLabel(message: import("discord.js").Message) {
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
}
function normalizeDiscordAllowList(
export function normalizeDiscordAllowList(
raw: Array<string | number> | undefined,
prefixes: string[],
): { allowAll: boolean; ids: Set<string> } | null {
): DiscordAllowList | null {
if (!raw || raw.length === 0) return null;
const cleaned = raw
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
for (const prefix of prefixes) {
if (entry.toLowerCase().startsWith(prefix)) {
return entry.slice(prefix.length);
}
const ids = new Set<string>();
const names = new Set<string>();
let allowAll = false;
for (const rawEntry of raw) {
let entry = String(rawEntry).trim();
if (!entry) continue;
if (entry === "*") {
allowAll = true;
continue;
}
for (const prefix of prefixes) {
if (entry.toLowerCase().startsWith(prefix)) {
entry = entry.slice(prefix.length);
break;
}
return entry;
});
const allowAll = cleaned.includes("*");
const ids = new Set(cleaned.filter((entry) => entry !== "*"));
return { allowAll, ids };
}
const mentionMatch = entry.match(/^<[@#][!]?(\d+)>$/);
if (mentionMatch?.[1]) {
ids.add(mentionMatch[1]);
continue;
}
entry = entry.trim();
if (entry.startsWith("@") || entry.startsWith("#")) {
entry = entry.slice(1);
}
if (/^\d+$/.test(entry)) {
ids.add(entry);
continue;
}
const normalized = normalizeDiscordName(entry);
if (normalized) names.add(normalized);
const slugged = normalizeDiscordSlug(entry);
if (slugged) names.add(slugged);
}
if (!allowAll && ids.size === 0 && names.size === 0) return null;
return { allowAll, ids, names };
}
function normalizeDiscordName(value?: string | null) {
if (!value) return "";
return value.trim().toLowerCase();
}
export function normalizeDiscordSlug(value?: string | null) {
if (!value) return "";
let text = value.trim().toLowerCase();
if (!text) return "";
text = text.replace(/^[@#]+/, "");
text = text.replace(/[\s_]+/g, "-");
text = text.replace(/[^a-z0-9-]+/g, "-");
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
return text;
}
export function allowListMatches(
allowList: DiscordAllowList,
candidates: {
id?: string;
name?: string | null;
tag?: string | null;
},
) {
if (allowList.allowAll) return true;
const { id, name, tag } = candidates;
if (id && allowList.ids.has(id)) return true;
const normalizedName = normalizeDiscordName(name);
if (normalizedName && allowList.names.has(normalizedName)) return true;
const normalizedTag = normalizeDiscordName(tag);
if (normalizedTag && allowList.names.has(normalizedTag)) return true;
const slugName = normalizeDiscordSlug(name);
if (slugName && allowList.names.has(slugName)) return true;
const slugTag = normalizeDiscordSlug(tag);
if (slugTag && allowList.names.has(slugTag)) return true;
return false;
}
export function resolveDiscordGuildEntry(params: {
guild: import("discord.js").Guild | null;
guildEntries: Record<string, DiscordGuildEntryResolved> | undefined;
}): DiscordGuildEntryResolved | null {
const { guild, guildEntries } = params;
if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) {
return null;
}
const guildId = guild.id;
const guildSlug = normalizeDiscordSlug(guild.name);
const direct = guildEntries[guildId];
if (direct) {
return {
id: guildId,
slug: direct.slug ?? guildSlug,
requireMention: direct.requireMention,
users: direct.users,
channels: direct.channels,
};
}
if (guildSlug && guildEntries[guildSlug]) {
const entry = guildEntries[guildSlug];
return {
id: guildId,
slug: entry.slug ?? guildSlug,
requireMention: entry.requireMention,
users: entry.users,
channels: entry.channels,
};
}
const matchBySlug = Object.entries(guildEntries).find(([, entry]) => {
const entrySlug = normalizeDiscordSlug(entry.slug);
return entrySlug && entrySlug === guildSlug;
});
if (matchBySlug) {
const entry = matchBySlug[1];
return {
id: guildId,
slug: entry.slug ?? guildSlug,
requireMention: entry.requireMention,
users: entry.users,
channels: entry.channels,
};
}
return null;
}
export function resolveDiscordChannelConfig(params: {
guildInfo: DiscordGuildEntryResolved | null;
channelId: string;
channelName?: string;
channelSlug?: string;
}): DiscordChannelConfigResolved | null {
const { guildInfo, channelId, channelName, channelSlug } = params;
const channelEntries = guildInfo?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const entry =
channelEntries[channelId] ??
(channelSlug
? channelEntries[channelSlug] ??
channelEntries[`#${channelSlug}`]
: undefined) ??
(channelName
? channelEntries[normalizeDiscordSlug(channelName)]
: undefined);
if (!entry) return { allowed: false };
return { allowed: entry.allow !== false, requireMention: entry.requireMention };
}
return { allowed: true };
}
export function resolveGroupDmAllow(params: {
channels: Array<string | number> | undefined;
channelId: string;
channelName?: string;
channelSlug?: string;
}) {
const { channels, channelId, channelName, channelSlug } = params;
if (!channels || channels.length === 0) return true;
const allowList = normalizeDiscordAllowList(channels, ["channel:"]);
if (!allowList) return true;
return allowListMatches(allowList, {
id: channelId,
name: channelSlug || channelName,
});
}
async function sendTyping(message: Message) {

View File

@@ -18,7 +18,13 @@ export type HookMappingResolved = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram" | "discord";
channel?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "signal"
| "imessage";
to?: string;
thinking?: string;
timeoutSeconds?: number;
@@ -50,7 +56,13 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat";
sessionKey?: string;
deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram" | "discord";
channel?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "signal"
| "imessage";
to?: string;
thinking?: string;
timeoutSeconds?: number;
@@ -86,7 +98,7 @@ type HookTransformResult = Partial<{
name: string;
sessionKey: string;
deliver: boolean;
channel: "last" | "whatsapp" | "telegram" | "discord";
channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
to: string;
thinking: string;
timeoutSeconds: number;

View File

@@ -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(

View File

@@ -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,
@@ -2153,9 +2210,6 @@ export async function startGatewayServer(
token: discordToken.trim(),
runtime: discordRuntimeEnv,
abortSignal: discordAbort.signal,
allowFrom: cfg.discord?.allowFrom,
guildAllowFrom: cfg.discord?.guildAllowFrom,
requireMention: cfg.discord?.requireMention,
mediaMaxMb: cfg.discord?.mediaMaxMb,
historyLimit: cfg.discord?.historyLimit,
})
@@ -2881,6 +2935,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,

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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.

View File

@@ -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",

View File

@@ -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;