feat: add discord guild map + group dm controls
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
||||||
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
||||||
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
||||||
|
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
|
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
|
||||||
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
|
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
|
||||||
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
|
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
|
||||||
|
- Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).
|
||||||
- Skills: add Trello skill for board/list/card management (thanks @clawd).
|
- Skills: add Trello skill for board/list/card management (thanks @clawd).
|
||||||
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
||||||
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
||||||
|
|||||||
@@ -177,22 +177,28 @@ Configure the Discord bot by setting the bot token and optional gating:
|
|||||||
enableReactions: true, // allow agent-triggered reactions
|
enableReactions: true, // allow agent-triggered reactions
|
||||||
dm: {
|
dm: {
|
||||||
enabled: true, // disable all DMs when false
|
enabled: true, // disable all DMs when false
|
||||||
allowFrom: ["1234567890", "steipete"] // optional DM allowlist (ids or names)
|
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
|
||||||
|
groupEnabled: false, // enable group DMs
|
||||||
|
groupChannels: ["clawd-dm"] // optional group DM allowlist
|
||||||
},
|
},
|
||||||
guild: {
|
guilds: {
|
||||||
channels: ["general", "help"], // optional channel allowlist (ids or names)
|
"123456789012345678": { // guild id (preferred) or slug
|
||||||
allowFrom: {
|
slug: "friends-of-clawd",
|
||||||
guilds: ["123456789012345678"], // optional guild allowlist (ids or names)
|
requireMention: false, // per-guild default
|
||||||
users: ["987654321098765432"] // optional user allowlist (ids or names)
|
users: ["987654321098765432"], // optional per-guild user allowlist
|
||||||
},
|
channels: {
|
||||||
requireMention: true, // require @bot mentions in guilds
|
general: { allow: true },
|
||||||
historyLimit: 20 // include last N guild messages as context
|
help: { allow: true, requireMention: true }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
historyLimit: 20 // include last N guild messages as context
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Clawdis reads `DISCORD_BOT_TOKEN` or `discord.token` to start the provider (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
Clawdis reads `DISCORD_BOT_TOKEN` or `discord.token` to start the provider (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
||||||
|
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
|
||||||
|
|
||||||
### `imessage` (imsg CLI)
|
### `imessage` (imsg CLI)
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Talk to Clawdis via Discord DMs or guild channels.
|
- Talk to Clawdis via Discord DMs or guild channels.
|
||||||
- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:<channelId>`.
|
- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:<channelId>` (display names use `discord:<guildSlug>#<channelSlug>`).
|
||||||
- Group DMs are treated as group sessions (separate from `main`) and show up with a `discord:g-...` display label.
|
- Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`.
|
||||||
- Keep routing deterministic: replies always go back to the surface they arrived on.
|
- Keep routing deterministic: replies always go back to the surface they arrived on.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
@@ -21,14 +21,14 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
|||||||
3. Configure Clawdis with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdis/clawdis.json`).
|
3. Configure Clawdis with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdis/clawdis.json`).
|
||||||
4. Run the gateway; it auto-starts the Discord provider when the token is set (unless `discord.enabled = false`).
|
4. Run the gateway; it auto-starts the Discord provider when the token is set (unless `discord.enabled = false`).
|
||||||
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
||||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default; disable with `discord.guild.requireMention = false` (legacy: `discord.requireMention`).
|
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
||||||
7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Legacy: `discord.allowFrom`.
|
7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs.
|
||||||
8. Optional guild allowlist: set `discord.guild.allowFrom` with `guilds` and/or `users` (ids or names) to gate who can invoke the bot in servers. Legacy: `discord.guildAllowFrom`.
|
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
|
||||||
9. Optional guild channel allowlist: set `discord.guild.channels` with channel ids or names to restrict where the bot listens.
|
9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||||
10. Optional guild context history: set `discord.guild.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable (legacy: `discord.historyLimit`).
|
10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
||||||
11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
|
||||||
|
|
||||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||||
|
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||||
|
|
||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||||
@@ -47,16 +47,20 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
|||||||
enableReactions: true,
|
enableReactions: true,
|
||||||
dm: {
|
dm: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowFrom: ["123456789012345678", "steipete"]
|
allowFrom: ["123456789012345678", "steipete"],
|
||||||
|
groupEnabled: false,
|
||||||
|
groupChannels: ["clawd-dm"]
|
||||||
},
|
},
|
||||||
guild: {
|
guilds: {
|
||||||
channels: ["general", "help"],
|
"123456789012345678": {
|
||||||
allowFrom: {
|
slug: "friends-of-clawd",
|
||||||
guilds: ["123456789012345678", "My Server"],
|
requireMention: false,
|
||||||
users: ["987654321098765432", "steipete"]
|
users: ["987654321098765432", "steipete"],
|
||||||
},
|
channels: {
|
||||||
requireMention: true,
|
general: { allow: true },
|
||||||
historyLimit: 20
|
help: { allow: true, requireMention: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,11 +68,15 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
|||||||
|
|
||||||
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||||
- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
|
- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
|
||||||
- `guild.allowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids or names). When both are set, both must match.
|
- `dm.groupEnabled`: enable group DMs (default `false`).
|
||||||
- `guild.channels`: Optional allowlist for channel ids or names.
|
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
||||||
- `guild.requireMention`: when `true`, messages in guild channels must mention the bot.
|
- `guilds`: per-guild rules keyed by guild id (preferred) or slug.
|
||||||
|
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
||||||
|
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||||
|
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||||
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `guild.historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||||
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
||||||
|
|
||||||
## Reactions
|
## Reactions
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
|
|||||||
## Mapping transports → session keys
|
## Mapping transports → session keys
|
||||||
- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context.
|
- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context.
|
||||||
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
|
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
|
||||||
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups.
|
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
|
||||||
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
|
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
|
||||||
|
|
||||||
## Lifecyle
|
## Lifecyle
|
||||||
|
|||||||
@@ -453,14 +453,20 @@ export async function getReplyFromConfig(
|
|||||||
if (groupResolution?.surface) {
|
if (groupResolution?.surface) {
|
||||||
const surface = groupResolution.surface;
|
const surface = groupResolution.surface;
|
||||||
const subject = ctx.GroupSubject?.trim();
|
const subject = ctx.GroupSubject?.trim();
|
||||||
|
const space = ctx.GroupSpace?.trim();
|
||||||
|
const explicitRoom = ctx.GroupRoom?.trim();
|
||||||
const isRoomSurface = surface === "discord" || surface === "slack";
|
const isRoomSurface = surface === "discord" || surface === "slack";
|
||||||
const nextRoom =
|
const nextRoom =
|
||||||
isRoomSurface && subject && subject.startsWith("#") ? subject : undefined;
|
explicitRoom ??
|
||||||
|
(isRoomSurface && subject && subject.startsWith("#")
|
||||||
|
? subject
|
||||||
|
: undefined);
|
||||||
const nextSubject = nextRoom ? undefined : subject;
|
const nextSubject = nextRoom ? undefined : subject;
|
||||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||||
sessionEntry.surface = surface;
|
sessionEntry.surface = surface;
|
||||||
if (nextSubject) sessionEntry.subject = nextSubject;
|
if (nextSubject) sessionEntry.subject = nextSubject;
|
||||||
if (nextRoom) sessionEntry.room = nextRoom;
|
if (nextRoom) sessionEntry.room = nextRoom;
|
||||||
|
if (space) sessionEntry.space = space;
|
||||||
sessionEntry.displayName = buildGroupDisplayName({
|
sessionEntry.displayName = buildGroupDisplayName({
|
||||||
surface: sessionEntry.surface,
|
surface: sessionEntry.surface,
|
||||||
subject: sessionEntry.subject,
|
subject: sessionEntry.subject,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type MsgContext = {
|
|||||||
Transcript?: string;
|
Transcript?: string;
|
||||||
ChatType?: string;
|
ChatType?: string;
|
||||||
GroupSubject?: string;
|
GroupSubject?: string;
|
||||||
|
GroupRoom?: string;
|
||||||
|
GroupSpace?: string;
|
||||||
GroupMembers?: string;
|
GroupMembers?: string;
|
||||||
SenderName?: string;
|
SenderName?: string;
|
||||||
SenderE164?: string;
|
SenderE164?: string;
|
||||||
|
|||||||
@@ -169,42 +169,35 @@ export type DiscordDmConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Allowlist for DM senders (ids or names). */
|
/** Allowlist for DM senders (ids or names). */
|
||||||
allowFrom?: Array<string | number>;
|
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 DiscordGuildConfig = {
|
export type DiscordGuildChannelConfig = {
|
||||||
/** Allowlist for guild messages (guilds/users by id or name). */
|
allow?: boolean;
|
||||||
allowFrom?: {
|
|
||||||
guilds?: Array<string | number>;
|
|
||||||
users?: Array<string | number>;
|
|
||||||
};
|
|
||||||
/** Allowlist for guild channels (ids or names). */
|
|
||||||
channels?: Array<string | number>;
|
|
||||||
/** Require @bot mention to respond in guilds. Default: true. */
|
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Number of recent guild messages to include for context. */
|
};
|
||||||
historyLimit?: number;
|
|
||||||
|
export type DiscordGuildEntry = {
|
||||||
|
slug?: string;
|
||||||
|
requireMention?: boolean;
|
||||||
|
users?: Array<string | number>;
|
||||||
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordConfig = {
|
export type DiscordConfig = {
|
||||||
/** If false, do not start the Discord provider. Default: true. */
|
/** If false, do not start the Discord provider. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
/** Legacy DM allowlist (ids). Prefer discord.dm.allowFrom. */
|
|
||||||
allowFrom?: Array<string | number>;
|
|
||||||
/** Legacy guild allowlist (ids). Prefer discord.guild.allowFrom. */
|
|
||||||
guildAllowFrom?: {
|
|
||||||
guilds?: Array<string | number>;
|
|
||||||
users?: Array<string | number>;
|
|
||||||
};
|
|
||||||
/** Legacy mention requirement. Prefer discord.guild.requireMention. */
|
|
||||||
requireMention?: boolean;
|
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Legacy history limit. Prefer discord.guild.historyLimit. */
|
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Allow agent-triggered Discord reactions (default: true). */
|
/** Allow agent-triggered Discord reactions (default: true). */
|
||||||
enableReactions?: boolean;
|
enableReactions?: boolean;
|
||||||
dm?: DiscordDmConfig;
|
dm?: DiscordDmConfig;
|
||||||
guild?: DiscordGuildConfig;
|
/** New per-guild config keyed by guild id or slug. */
|
||||||
|
guilds?: Record<string, DiscordGuildEntry>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignalConfig = {
|
export type SignalConfig = {
|
||||||
@@ -934,14 +927,6 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
token: z.string().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(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
enableReactions: z.boolean().optional(),
|
enableReactions: z.boolean().optional(),
|
||||||
@@ -949,8 +934,33 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.union([z.string(), z.number()])).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(),
|
.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
|
guild: z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z
|
allowFrom: z
|
||||||
|
|||||||
@@ -116,11 +116,13 @@ export function buildGroupDisplayName(params: {
|
|||||||
key: string;
|
key: string;
|
||||||
}) {
|
}) {
|
||||||
const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim();
|
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 =
|
const detail =
|
||||||
params.room?.trim() ||
|
(room && space
|
||||||
params.subject?.trim() ||
|
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
|
||||||
params.space?.trim() ||
|
: room || subject || space || "") || "";
|
||||||
"";
|
|
||||||
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
|
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
|
||||||
const rawLabel = detail || fallbackId;
|
const rawLabel = detail || fallbackId;
|
||||||
let token = normalizeGroupLabel(rawLabel);
|
let token = normalizeGroupLabel(rawLabel);
|
||||||
@@ -130,7 +132,12 @@ export function buildGroupDisplayName(params: {
|
|||||||
if (!params.room && token.startsWith("#")) {
|
if (!params.room && token.startsWith("#")) {
|
||||||
token = token.replace(/^#+/, "");
|
token = token.replace(/^#+/, "");
|
||||||
}
|
}
|
||||||
if (token && !/^[@#]/.test(token) && !token.startsWith("g-")) {
|
if (
|
||||||
|
token &&
|
||||||
|
!/^[@#]/.test(token) &&
|
||||||
|
!token.startsWith("g-") &&
|
||||||
|
!token.includes("#")
|
||||||
|
) {
|
||||||
token = `g-${token}`;
|
token = `g-${token}`;
|
||||||
}
|
}
|
||||||
return token ? `${surfaceKey}:${token}` : surfaceKey;
|
return token ? `${surfaceKey}:${token}` : surfaceKey;
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ export type MonitorDiscordOpts = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
allowFrom?: Array<string | number>;
|
|
||||||
guildAllowFrom?: {
|
|
||||||
guilds?: Array<string | number>;
|
|
||||||
users?: Array<string | number>;
|
|
||||||
};
|
|
||||||
requireMention?: boolean;
|
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
};
|
};
|
||||||
@@ -54,6 +48,19 @@ type DiscordAllowList = {
|
|||||||
names: Set<string>;
|
names: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DiscordGuildEntryResolved = {
|
||||||
|
id?: string;
|
||||||
|
slug?: string;
|
||||||
|
requireMention?: boolean;
|
||||||
|
users?: Array<string | number>;
|
||||||
|
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscordChannelConfigResolved = {
|
||||||
|
allowed: boolean;
|
||||||
|
requireMention?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const token = normalizeDiscordToken(
|
const token = normalizeDiscordToken(
|
||||||
@@ -77,29 +84,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dmConfig = cfg.discord?.dm;
|
const dmConfig = cfg.discord?.dm;
|
||||||
const guildConfig = cfg.discord?.guild;
|
const guildEntries = cfg.discord?.guilds;
|
||||||
const allowFrom =
|
const allowFrom = dmConfig?.allowFrom;
|
||||||
opts.allowFrom ?? dmConfig?.allowFrom ?? cfg.discord?.allowFrom;
|
|
||||||
const guildAllowFrom =
|
|
||||||
opts.guildAllowFrom ??
|
|
||||||
guildConfig?.allowFrom ??
|
|
||||||
cfg.discord?.guildAllowFrom;
|
|
||||||
const guildChannels = guildConfig?.channels;
|
|
||||||
const requireMention =
|
|
||||||
opts.requireMention ??
|
|
||||||
guildConfig?.requireMention ??
|
|
||||||
cfg.discord?.requireMention ??
|
|
||||||
true;
|
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const historyLimit = Math.max(
|
const historyLimit = Math.max(
|
||||||
0,
|
0,
|
||||||
opts.historyLimit ??
|
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
|
||||||
guildConfig?.historyLimit ??
|
|
||||||
cfg.discord?.historyLimit ??
|
|
||||||
20,
|
|
||||||
);
|
);
|
||||||
const dmEnabled = dmConfig?.enabled ?? true;
|
const dmEnabled = dmConfig?.enabled ?? true;
|
||||||
|
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||||
|
const groupDmChannels = dmConfig?.groupChannels;
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -128,8 +123,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
if (!message.author) return;
|
if (!message.author) return;
|
||||||
|
|
||||||
const channelType = message.channel.type;
|
const channelType = message.channel.type;
|
||||||
|
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||||
const isDirectMessage = channelType === ChannelType.DM;
|
const isDirectMessage = channelType === ChannelType.DM;
|
||||||
const isGuildMessage = Boolean(message.guild);
|
const isGuildMessage = Boolean(message.guild);
|
||||||
|
if (isGroupDm && !groupDmEnabled) return;
|
||||||
if (isDirectMessage && !dmEnabled) return;
|
if (isDirectMessage && !dmEnabled) return;
|
||||||
const botId = client.user?.id;
|
const botId = client.user?.id;
|
||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
@@ -141,6 +138,58 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
message.embeds[0]?.description ||
|
message.embeds[0]?.description ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
|
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) {
|
if (isGuildMessage && historyLimit > 0 && baseText) {
|
||||||
const history = guildHistories.get(message.channelId) ?? [];
|
const history = guildHistories.get(message.channelId) ?? [];
|
||||||
history.push({
|
history.push({
|
||||||
@@ -153,7 +202,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
guildHistories.set(message.channelId, history);
|
guildHistories.set(message.channelId, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGuildMessage && requireMention) {
|
const resolvedRequireMention =
|
||||||
|
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
|
||||||
|
if (isGuildMessage && resolvedRequireMention) {
|
||||||
if (botId && !wasMentioned) {
|
if (botId && !wasMentioned) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -167,56 +218,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isGuildMessage) {
|
if (isGuildMessage) {
|
||||||
const channelAllow = normalizeDiscordAllowList(guildChannels, [
|
const userAllow = guildInfo?.users;
|
||||||
"channel:",
|
if (Array.isArray(userAllow) && userAllow.length > 0) {
|
||||||
]);
|
const users = normalizeDiscordAllowList(userAllow, [
|
||||||
if (channelAllow) {
|
"discord:",
|
||||||
const channelName =
|
"user:",
|
||||||
"name" in message.channel ? message.channel.name : undefined;
|
]);
|
||||||
const channelOk = allowListMatches(channelAllow, {
|
|
||||||
id: message.channelId,
|
|
||||||
name: channelName,
|
|
||||||
});
|
|
||||||
if (!channelOk) {
|
|
||||||
logVerbose(
|
|
||||||
`Blocked discord channel ${message.channelId} not in guild.channels`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGuildMessage && 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 ||
|
|
||||||
allowListMatches(guilds, {
|
|
||||||
id: guildId,
|
|
||||||
name: message.guild?.name,
|
|
||||||
});
|
|
||||||
const userOk =
|
const userOk =
|
||||||
!users ||
|
!users ||
|
||||||
allowListMatches(users, {
|
allowListMatches(users, {
|
||||||
id: userId,
|
id: message.author.id,
|
||||||
name: message.author.username,
|
name: message.author.username,
|
||||||
tag: message.author.tag,
|
tag: message.author.tag,
|
||||||
});
|
});
|
||||||
if (!guildOk || !userOk) {
|
if (!userOk) {
|
||||||
logVerbose(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
@@ -250,13 +272,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const fromLabel = isDirectMessage
|
const fromLabel = isDirectMessage
|
||||||
? buildDirectLabel(message)
|
? buildDirectLabel(message)
|
||||||
: buildGuildLabel(message);
|
: buildGuildLabel(message);
|
||||||
const groupSubject = (() => {
|
const groupRoom =
|
||||||
if (isDirectMessage) return undefined;
|
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
|
||||||
const channelName =
|
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
||||||
"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}]`;
|
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
|
||||||
let combinedBody = formatAgentEnvelope({
|
let combinedBody = formatAgentEnvelope({
|
||||||
surface: "Discord",
|
surface: "Discord",
|
||||||
@@ -298,6 +316,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
ChatType: isDirectMessage ? "direct" : "group",
|
ChatType: isDirectMessage ? "direct" : "group",
|
||||||
SenderName: message.member?.displayName ?? message.author.tag,
|
SenderName: message.member?.displayName ?? message.author.tag,
|
||||||
GroupSubject: groupSubject,
|
GroupSubject: groupSubject,
|
||||||
|
GroupRoom: groupRoom,
|
||||||
|
GroupSpace: isGuildMessage ? guildSlug || undefined : undefined,
|
||||||
Surface: "discord" as const,
|
Surface: "discord" as const,
|
||||||
WasMentioned: wasMentioned,
|
WasMentioned: wasMentioned,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
@@ -457,6 +477,8 @@ function normalizeDiscordAllowList(
|
|||||||
}
|
}
|
||||||
const normalized = normalizeDiscordName(entry);
|
const normalized = normalizeDiscordName(entry);
|
||||||
if (normalized) names.add(normalized);
|
if (normalized) names.add(normalized);
|
||||||
|
const slugged = normalizeDiscordSlug(entry);
|
||||||
|
if (slugged) names.add(slugged);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowAll && ids.size === 0 && names.size === 0) return null;
|
if (!allowAll && ids.size === 0 && names.size === 0) return null;
|
||||||
@@ -468,6 +490,17 @@ function normalizeDiscordName(value?: string | null) {
|
|||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function allowListMatches(
|
function allowListMatches(
|
||||||
allowList: DiscordAllowList,
|
allowList: DiscordAllowList,
|
||||||
candidates: {
|
candidates: {
|
||||||
@@ -483,9 +516,100 @@ function allowListMatches(
|
|||||||
if (normalizedName && allowList.names.has(normalizedName)) return true;
|
if (normalizedName && allowList.names.has(normalizedName)) return true;
|
||||||
const normalizedTag = normalizeDiscordName(tag);
|
const normalizedTag = normalizeDiscordName(tag);
|
||||||
if (normalizedTag && allowList.names.has(normalizedTag)) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function sendTyping(message: Message) {
|
||||||
try {
|
try {
|
||||||
const channel = message.channel;
|
const channel = message.channel;
|
||||||
|
|||||||
@@ -2210,9 +2210,6 @@ export async function startGatewayServer(
|
|||||||
token: discordToken.trim(),
|
token: discordToken.trim(),
|
||||||
runtime: discordRuntimeEnv,
|
runtime: discordRuntimeEnv,
|
||||||
abortSignal: discordAbort.signal,
|
abortSignal: discordAbort.signal,
|
||||||
allowFrom: cfg.discord?.allowFrom,
|
|
||||||
guildAllowFrom: cfg.discord?.guildAllowFrom,
|
|
||||||
requireMention: cfg.discord?.requireMention,
|
|
||||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||||
historyLimit: cfg.discord?.historyLimit,
|
historyLimit: cfg.discord?.historyLimit,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user