diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 53469f225..f24ad6971 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -58,7 +58,7 @@ Minimal config: - The `discord` tool is only exposed when the current channel is Discord. 13. Native commands use isolated session keys (`agent::discord:slash:`) rather than the shared `main` session. -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: Name → id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions. Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy. @@ -193,8 +193,11 @@ Notes: - Your config requires mentions and you didn’t mention it, or - Your guild/channel allowlist denies the channel/user. - **`requireMention: false` but still no replies**: - - `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds..channels` to restrict). - - `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored. +- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds..channels` to restrict). + - If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime + defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`, + `channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down. +- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored. - **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions. - **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`). @@ -362,6 +365,10 @@ Allowlist matching notes: - Use `*` to allow any sender/channel. - When `guilds..channels` is present, channels not listed are denied by default. - When `guilds..channels` is omitted, all channels in the allowlisted guild are allowed. +- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist). +- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible. +- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when the bot can search members) + and logs the mapping; unresolved entries are kept as typed. Native command notes: - The registered commands mirror Clawdbot’s chat commands. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index b2349ca50..d0b632cb6 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -70,9 +70,10 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis - `clawdbot pairing list matrix` - `clawdbot pairing approve matrix ` - Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`. +- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available). ## Rooms (groups) -- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). +- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. - Allowlist rooms with `channels.matrix.rooms`: ```json5 { @@ -86,6 +87,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis } ``` - `requireMention: false` enables auto-reply in that room. +- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible. +- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. +- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). ## Threads - Reply threading is supported. diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 79f96bbba..c8c668e84 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -76,12 +76,13 @@ Disable with: **DM access** - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs. +- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows). **Group access** -- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). +- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset. - `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`). - Set `groupPolicy: "open"` to allow any member (still mention‑gated by default). +- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`. Example: ```json5 @@ -95,6 +96,32 @@ Example: } ``` +**Teams + channel allowlist** +- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`. +- Keys can be team IDs or names; channel keys can be conversation IDs or names. +- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated). +- The configure wizard accepts `Team/Channel` entries and stores them for you. +- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow) + and logs the mapping; unresolved entries are kept as typed. + +Example: +```json5 +{ + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "My Team": { + channels: { + "General": { requireMention: true } + } + } + } + } + } +} +``` + ## How it works 1. Install the Microsoft Teams plugin. 2. Create an **Azure Bot** (App ID + secret + tenant ID). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 9f85b706b..3adeff733 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -343,10 +343,19 @@ For fine-grained control, use these tags in agent responses: - Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour). - Approve via: `clawdbot pairing approve slack `. - To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`. +- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). ## Group policy - `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`). - `allowlist` requires channels to be listed in `channels.slack.channels`. + - If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section, + the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`, + `channels.defaults.groupPolicy`, or a channel allowlist to lock it down. + - The configure wizard accepts `#channel` names and resolves them to IDs when possible + (public + private); if multiple matches exist, it prefers the active channel. + - On startup, Clawdbot resolves channel/user names in allowlists to IDs (when tokens allow) + and logs the mapping; unresolved entries are kept as typed. + - To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist). Channel options (`channels.slack.channels.` or `channels.slack.channels.`): - `allow`: allow/deny the channel when `groupPolicy="allowlist"`. diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index 495c59286..a667a3f82 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -66,11 +66,36 @@ clawdbot directory groups list --channel zalouser --query "work" ## Access control (DMs) `channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). +`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available). Approve via: - `clawdbot pairing list zalouser` - `clawdbot pairing approve zalouser ` +## Group access (optional) +- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset. +- Restrict to an allowlist with: + - `channels.zalouser.groupPolicy = "allowlist"` + - `channels.zalouser.groups` (keys are group IDs or names) +- Block all groups: `channels.zalouser.groupPolicy = "disabled"`. +- The configure wizard can prompt for group allowlists. +- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. + +Example: +```json5 +{ + channels: { + zalouser: { + groupPolicy: "allowlist", + groups: { + "123456789": { allow: true }, + "Work Chat": { allow: true } + } + } + } +} +``` + ## Multi-account Accounts map to zca profiles. Example: diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 701a22a0c..55214ae63 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -20,6 +20,7 @@ clawdbot channels list clawdbot channels status clawdbot channels capabilities clawdbot channels capabilities --channel discord --target channel:123 +clawdbot channels resolve --channel slack "#general" "@jane" clawdbot channels logs --channel all ``` @@ -57,3 +58,17 @@ Notes: - `--channel` is optional; omit it to list every channel (including extensions). - `--target` accepts `channel:` or a raw numeric channel id and only applies to Discord. - Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`. + +## Resolve names to IDs + +Resolve channel/user names to IDs using the provider directory: + +```bash +clawdbot channels resolve --channel slack "#general" "@jane" +clawdbot channels resolve --channel discord "My Server/#support" "@someone" +clawdbot channels resolve --channel matrix "Project Room" +``` + +Notes: +- Use `--kind user|group|auto` to force the target type. +- Resolution prefers active matches when multiple entries share the same name. diff --git a/docs/cli/configure.md b/docs/cli/configure.md index f16421ec1..2ffa23de6 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -17,6 +17,7 @@ Related: Notes: - Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need. +- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible. ## Examples diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ef73fb705..b0b7da1e9 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -678,10 +678,11 @@ Notes: - `"open"`: groups bypass allowlists; mention-gating still applies. - `"disabled"`: block all group/room messages. - `"allowlist"`: only allow groups/rooms that match the configured allowlist. +- `channels.defaults.groupPolicy` sets the default when a provider’s `groupPolicy` is unset. - WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`). - Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`). - Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`. -- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked. +- Default is `groupPolicy: "allowlist"` (unless overridden by `channels.defaults.groupPolicy`); if no allowlist is configured, group messages are blocked. ### Multi-agent routing (`agents.list` + `bindings`) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index ed964e74f..8be09ca31 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -293,6 +293,7 @@ Typical fields in `~/.clawdbot/clawdbot.json`: - `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` +- Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible). - `skills.install.nodeManager` - `wizard.lastRunAt` - `wizard.lastRunVersion` diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 9b2a0efc2..79cbc974a 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -25,6 +25,10 @@ import { probeMatrix } from "./matrix/probe.js"; import { sendMessageMatrix } from "./matrix/send.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; import { matrixOutbound } from "./outbound.js"; +import { + listMatrixDirectoryGroupsLive, + listMatrixDirectoryPeersLive, +} from "./directory-live.js"; const meta = { id: "matrix", @@ -147,8 +151,9 @@ export const matrixPlugin: ChannelPlugin = { approveHint: formatPairingApproveHint("matrix"), normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(), }), - collectWarnings: ({ account }) => { - const groupPolicy = account.config.groupPolicy ?? "allowlist"; + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; return [ "- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.", @@ -234,6 +239,87 @@ export const matrixPlugin: ChannelPlugin = { .map((id) => ({ kind: "group", id }) as const); return ids; }, + listPeersLive: async ({ cfg, query, limit }) => + listMatrixDirectoryPeersLive({ cfg, query, limit }), + listGroupsLive: async ({ cfg, query, limit }) => + listMatrixDirectoryGroupsLive({ cfg, query, limit }), + }, + resolver: { + resolveTargets: async ({ cfg, inputs, kind, runtime }) => { + const results = []; + for (const input of inputs) { + const trimmed = input.trim(); + if (!trimmed) { + results.push({ input, resolved: false, note: "empty input" }); + continue; + } + if (kind === "user") { + if (trimmed.startsWith("@") && trimmed.includes(":")) { + results.push({ input, resolved: true, id: trimmed }); + continue; + } + try { + const matches = await listMatrixDirectoryPeersLive({ + cfg, + query: trimmed, + limit: 5, + }); + const best = matches[0]; + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + runtime.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + continue; + } + if (trimmed.startsWith("!") || trimmed.startsWith("#")) { + try { + const matches = await listMatrixDirectoryGroupsLive({ + cfg, + query: trimmed, + limit: 5, + }); + const best = matches[0]; + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + runtime.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + continue; + } + try { + const matches = await listMatrixDirectoryGroupsLive({ + cfg, + query: trimmed, + limit: 5, + }); + const best = matches[0]; + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + runtime.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + } + return results; + }, }, actions: matrixMessageActions, setup: { diff --git a/extensions/matrix/src/directory-live.ts b/extensions/matrix/src/directory-live.ts new file mode 100644 index 000000000..c4784eecd --- /dev/null +++ b/extensions/matrix/src/directory-live.ts @@ -0,0 +1,175 @@ +import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; + +import { resolveMatrixAuth } from "./matrix/client.js"; + +type MatrixUserResult = { + user_id?: string; + display_name?: string; +}; + +type MatrixUserDirectoryResponse = { + results?: MatrixUserResult[]; +}; + +type MatrixJoinedRoomsResponse = { + joined_rooms?: string[]; +}; + +type MatrixRoomNameState = { + name?: string; +}; + +type MatrixAliasLookup = { + room_id?: string; +}; + +async function fetchMatrixJson(params: { + homeserver: string; + path: string; + accessToken: string; + method?: "GET" | "POST"; + body?: unknown; +}): Promise { + const res = await fetch(`${params.homeserver}${params.path}`, { + method: params.method ?? "GET", + headers: { + Authorization: `Bearer ${params.accessToken}`, + "Content-Type": "application/json", + }, + body: params.body ? JSON.stringify(params.body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim().toLowerCase() ?? ""; +} + +export async function listMatrixDirectoryPeersLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) return []; + const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const res = await fetchMatrixJson({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + path: "/_matrix/client/v3/user_directory/search", + method: "POST", + body: { + search_term: query, + limit: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20, + }, + }); + const results = res.results ?? []; + return results + .map((entry) => { + const userId = entry.user_id?.trim(); + if (!userId) return null; + return { + kind: "user", + id: userId, + name: entry.display_name?.trim() || undefined, + handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined, + raw: entry, + } satisfies ChannelDirectoryEntry; + }) + .filter(Boolean) as ChannelDirectoryEntry[]; +} + +async function resolveMatrixRoomAlias( + homeserver: string, + accessToken: string, + alias: string, +): Promise { + try { + const res = await fetchMatrixJson({ + homeserver, + accessToken, + path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + }); + return res.room_id?.trim() || null; + } catch { + return null; + } +} + +async function fetchMatrixRoomName( + homeserver: string, + accessToken: string, + roomId: string, +): Promise { + try { + const res = await fetchMatrixJson({ + homeserver, + accessToken, + path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`, + }); + return res.name?.trim() || null; + } catch { + return null; + } +} + +export async function listMatrixDirectoryGroupsLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) return []; + const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; + + if (query.startsWith("#")) { + const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query); + if (!roomId) return []; + return [ + { + kind: "group", + id: roomId, + name: query, + handle: query, + } satisfies ChannelDirectoryEntry, + ]; + } + + if (query.startsWith("!")) { + return [ + { + kind: "group", + id: query, + name: query, + } satisfies ChannelDirectoryEntry, + ]; + } + + const joined = await fetchMatrixJson({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + path: "/_matrix/client/v3/joined_rooms", + }); + const rooms = joined.joined_rooms ?? []; + const results: ChannelDirectoryEntry[] = []; + + for (const roomId of rooms) { + const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId); + if (!name) continue; + if (!name.toLowerCase().includes(query)) continue; + results.push({ + kind: "group", + id: roomId, + name, + handle: `#${name}`, + }); + if (results.length >= limit) break; + } + + return results; +} diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 3dc7f13a3..5e9bfa877 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -53,6 +53,56 @@ import { resolveMentions } from "./mentions.js"; import { deliverMatrixReplies } from "./replies.js"; import { resolveMatrixRoomConfig } from "./rooms.js"; import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; +import { + listMatrixDirectoryGroupsLive, + listMatrixDirectoryPeersLive, +} from "../../directory-live.js"; + +function mergeAllowlist(params: { + existing?: Array; + additions: string[]; +}): string[] { + const seen = new Set(); + const merged: string[] = []; + const push = (value: string) => { + const normalized = value.trim(); + if (!normalized) return; + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + merged.push(normalized); + }; + for (const entry of params.existing ?? []) { + push(String(entry)); + } + for (const entry of params.additions) { + push(entry); + } + return merged; +} + +function summarizeMapping( + label: string, + mapping: string[], + unresolved: string[], + runtime: RuntimeEnv, +) { + const lines: string[] = []; + if (mapping.length > 0) { + const sample = mapping.slice(0, 6); + const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : ""; + lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`); + } + if (unresolved.length > 0) { + const sample = unresolved.slice(0, 6); + const suffix = + unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : ""; + lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`); + } + if (lines.length > 0) { + runtime.log?.(lines.join("\n")); + } +} export type MonitorMatrixOpts = { runtime?: RuntimeEnv; @@ -68,7 +118,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi if (isBunRuntime()) { throw new Error("Matrix provider requires Node (bun runtime not supported)"); } - const cfg = loadConfig() as CoreConfig; + let cfg = loadConfig() as CoreConfig; if (cfg.channels?.matrix?.enabled === false) return; const runtime: RuntimeEnv = opts.runtime ?? { @@ -79,6 +129,109 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }, }; + const normalizeUserEntry = (raw: string) => + raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim(); + const normalizeRoomEntry = (raw: string) => + raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim(); + const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + let roomsConfig = cfg.channels?.matrix?.rooms; + + if (allowFrom.length > 0) { + const entries = allowFrom + .map((entry) => normalizeUserEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + if (entries.length > 0) { + const mapping: string[] = []; + const unresolved: string[] = []; + const additions: string[] = []; + for (const entry of entries) { + if (isMatrixUserId(entry)) { + additions.push(entry); + continue; + } + try { + const matches = await listMatrixDirectoryPeersLive({ + cfg, + query: entry, + limit: 5, + }); + const best = matches[0]; + if (best?.id) { + additions.push(best.id); + mapping.push(`${entry}→${best.id}`); + } else { + unresolved.push(entry); + } + } catch (err) { + runtime.log?.(`matrix user resolve failed; using config entries. ${String(err)}`); + unresolved.push(entry); + } + } + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + summarizeMapping("matrix users", mapping, unresolved, runtime); + } + } + + if (roomsConfig && Object.keys(roomsConfig).length > 0) { + const entries = Object.keys(roomsConfig).filter((key) => key !== "*"); + const mapping: string[] = []; + const unresolved: string[] = []; + const nextRooms = { ...roomsConfig }; + for (const entry of entries) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const cleaned = normalizeRoomEntry(trimmed); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + if (!nextRooms[cleaned]) { + nextRooms[cleaned] = roomsConfig[entry]; + } + mapping.push(`${entry}→${cleaned}`); + continue; + } + try { + const matches = await listMatrixDirectoryGroupsLive({ + cfg, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + if (!nextRooms[best.id]) { + nextRooms[best.id] = roomsConfig[entry]; + } + mapping.push(`${entry}→${best.id}`); + } else { + unresolved.push(entry); + } + } catch (err) { + runtime.log?.(`matrix room resolve failed; using config entries. ${String(err)}`); + unresolved.push(entry); + } + } + roomsConfig = nextRooms; + summarizeMapping("matrix rooms", mapping, unresolved, runtime); + } + + cfg = { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + dm: { + ...cfg.channels?.matrix?.dm, + allowFrom, + }, + rooms: roomsConfig, + }, + }, + }; + const auth = await resolveMatrixAuth({ cfg }); const resolvedInitialSyncLimit = typeof opts.initialSyncLimit === "number" @@ -98,7 +251,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const mentionRegexes = buildMentionRegexes(cfg); const logger = getChildLogger({ module: "matrix-auto-reply" }); const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; - const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? "allowlist"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off"; const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound"; diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 1f00c3581..4151f32c7 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -3,8 +3,10 @@ import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import type { CoreConfig, DmPolicy } from "./types.js"; @@ -83,6 +85,35 @@ async function promptMatrixAllowFrom(params: { }; } +function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + groupPolicy, + }, + }, + }; +} + +function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) { + const rooms = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + rooms, + }, + }, + }; +} + const dmPolicy: ChannelOnboardingDmPolicy = { label: "Matrix", channel, @@ -254,6 +285,75 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { next = await promptMatrixAllowFrom({ cfg: next, prompter }); } + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Matrix rooms", + currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist", + currentEntries: Object.keys(next.channels?.matrix?.rooms ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(next.channels?.matrix?.rooms), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [ + ...resolvedIds, + ...unresolved.map((entry) => entry.trim()).filter(Boolean), + ]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Matrix rooms", + ); + } + } catch (err) { + await prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist"); + next = setMatrixRoomAllowlist(next, roomKeys); + } + } + return { cfg: next }; }, dmPolicy, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index f72a2da11..08245c91d 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -8,8 +8,16 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; import { msteamsOutbound } from "./outbound.js"; import { probeMSTeams } from "./probe.js"; +import { + resolveMSTeamsChannelAllowlist, + resolveMSTeamsUserAllowlist, +} from "./resolve-allowlist.js"; import { sendMessageMSTeams } from "./send.js"; import { resolveMSTeamsCredentials } from "./token.js"; +import { + listMSTeamsDirectoryGroupsLive, + listMSTeamsDirectoryPeersLive, +} from "./directory-live.js"; type ResolvedMSTeamsAccount = { accountId: string; @@ -112,7 +120,8 @@ export const msteamsPlugin: ChannelPlugin = { }, security: { collectWarnings: ({ cfg }) => { - const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? "allowlist"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; return [ `- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`, @@ -189,6 +198,137 @@ export const msteamsPlugin: ChannelPlugin = { .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => ({ kind: "group", id }) as const); }, + listPeersLive: async ({ cfg, query, limit }) => + listMSTeamsDirectoryPeersLive({ cfg, query, limit }), + listGroupsLive: async ({ cfg, query, limit }) => + listMSTeamsDirectoryGroupsLive({ cfg, query, limit }), + }, + resolver: { + resolveTargets: async ({ cfg, inputs, kind, runtime }) => { + const results = inputs.map((input) => ({ + input, + resolved: false, + id: undefined as string | undefined, + name: undefined as string | undefined, + note: undefined as string | undefined, + })); + + const stripPrefix = (value: string) => + value + .replace(/^(msteams|teams):/i, "") + .replace(/^(user|conversation):/i, "") + .trim(); + + if (kind === "user") { + const pending: Array<{ input: string; query: string; index: number }> = []; + results.forEach((entry, index) => { + const trimmed = entry.input.trim(); + if (!trimmed) { + entry.note = "empty input"; + return; + } + const cleaned = stripPrefix(trimmed); + if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) { + entry.resolved = true; + entry.id = cleaned; + return; + } + pending.push({ input: entry.input, query: cleaned, index }); + }); + + if (pending.length > 0) { + try { + const resolved = await resolveMSTeamsUserAllowlist({ + cfg, + entries: pending.map((entry) => entry.query), + }); + resolved.forEach((entry, idx) => { + const target = results[pending[idx]?.index ?? -1]; + if (!target) return; + target.resolved = entry.resolved; + target.id = entry.id; + target.name = entry.name; + target.note = entry.note; + }); + } catch (err) { + runtime.error?.(`msteams resolve failed: ${String(err)}`); + pending.forEach(({ index }) => { + const entry = results[index]; + if (entry) entry.note = "lookup failed"; + }); + } + } + + return results; + } + + const pending: Array<{ input: string; query: string; index: number }> = []; + results.forEach((entry, index) => { + const trimmed = entry.input.trim(); + if (!trimmed) { + entry.note = "empty input"; + return; + } + if (/^conversation:/i.test(trimmed)) { + const id = trimmed.replace(/^conversation:/i, "").trim(); + if (id) { + entry.resolved = true; + entry.id = id; + entry.note = "conversation id"; + } else { + entry.note = "empty conversation id"; + } + return; + } + pending.push({ + input: entry.input, + query: trimmed + .replace(/^(msteams|teams):/i, "") + .replace(/^team:/i, "") + .trim(), + index, + }); + }); + + if (pending.length > 0) { + try { + const resolved = await resolveMSTeamsChannelAllowlist({ + cfg, + entries: pending.map((entry) => entry.query), + }); + resolved.forEach((entry, idx) => { + const target = results[pending[idx]?.index ?? -1]; + if (!target) return; + if (!entry.resolved || !entry.teamId) { + target.resolved = false; + target.note = entry.note; + return; + } + target.resolved = true; + if (entry.channelId) { + target.id = `${entry.teamId}/${entry.channelId}`; + target.name = + entry.channelName && entry.teamName + ? `${entry.teamName}/${entry.channelName}` + : entry.channelName ?? entry.teamName; + } else { + target.id = entry.teamId; + target.name = entry.teamName; + target.note = "team id"; + } + if (entry.note) target.note = entry.note; + }); + } catch (err) { + runtime.error?.(`msteams resolve failed: ${String(err)}`); + pending.forEach(({ index }) => { + const entry = results[index]; + if (entry) entry.note = "lookup failed"; + }); + } + } + + return results; + }, }, actions: { listActions: ({ cfg }) => { diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts new file mode 100644 index 000000000..6518959ad --- /dev/null +++ b/extensions/msteams/src/directory-live.ts @@ -0,0 +1,179 @@ +import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; + +import { GRAPH_ROOT } from "./attachments/shared.js"; +import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +type GraphUser = { + id?: string; + displayName?: string; + userPrincipalName?: string; + mail?: string; +}; + +type GraphGroup = { + id?: string; + displayName?: string; +}; + +type GraphChannel = { + id?: string; + displayName?: string; +}; + +type GraphResponse = { value?: T[] }; + +function readAccessToken(value: unknown): string | null { + if (typeof value === "string") return value; + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim() ?? ""; +} + +function escapeOData(value: string): string { + return value.replace(/'/g, "''"); +} + +async function fetchGraphJson(params: { + token: string; + path: string; + headers?: Record; +}): Promise { + const res = await fetch(`${GRAPH_ROOT}${params.path}`, { + headers: { + Authorization: `Bearer ${params.token}`, + ...(params.headers ?? {}), + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +async function resolveGraphToken(cfg: unknown): Promise { + const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams); + if (!creds) throw new Error("MS Teams credentials missing"); + const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); + const tokenProvider = new sdk.MsalTokenProvider(authConfig); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + const accessToken = readAccessToken(token); + if (!accessToken) throw new Error("MS Teams graph token unavailable"); + return accessToken; +} + +async function listTeamsByName(token: string, query: string): Promise { + const escaped = escapeOData(query); + const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`; + const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +async function listChannelsForTeam(token: string, teamId: string): Promise { + const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +export async function listMSTeamsDirectoryPeersLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) return []; + const token = await resolveGraphToken(params.cfg); + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; + + let users: GraphUser[] = []; + if (query.includes("@")) { + const escaped = escapeOData(query); + const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; + const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; + const res = await fetchGraphJson>({ token, path }); + users = res.value ?? []; + } else { + const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`; + const res = await fetchGraphJson>({ + token, + path, + headers: { ConsistencyLevel: "eventual" }, + }); + users = res.value ?? []; + } + + return users + .map((user) => { + const id = user.id?.trim(); + if (!id) return null; + const name = user.displayName?.trim(); + const handle = user.userPrincipalName?.trim() || user.mail?.trim(); + return { + kind: "user", + id: `user:${id}`, + name: name || undefined, + handle: handle ? `@${handle}` : undefined, + raw: user, + } satisfies ChannelDirectoryEntry; + }) + .filter(Boolean) as ChannelDirectoryEntry[]; +} + +export async function listMSTeamsDirectoryGroupsLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const rawQuery = normalizeQuery(params.query); + if (!rawQuery) return []; + const token = await resolveGraphToken(params.cfg); + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; + const [teamQuery, channelQuery] = rawQuery.includes("/") + ? rawQuery.split("/", 2).map((part) => part.trim()).filter(Boolean) + : [rawQuery, null]; + + const teams = await listTeamsByName(token, teamQuery); + const results: ChannelDirectoryEntry[] = []; + + for (const team of teams) { + const teamId = team.id?.trim(); + if (!teamId) continue; + const teamName = team.displayName?.trim() || teamQuery; + if (!channelQuery) { + results.push({ + kind: "group", + id: `team:${teamId}`, + name: teamName, + handle: teamName ? `#${teamName}` : undefined, + raw: team, + }); + if (results.length >= limit) return results; + continue; + } + const channels = await listChannelsForTeam(token, teamId); + for (const channel of channels) { + const name = channel.displayName?.trim(); + if (!name) continue; + if (!name.toLowerCase().includes(channelQuery.toLowerCase())) continue; + results.push({ + kind: "group", + id: `conversation:${channel.id}`, + name: `${teamName}/${name}`, + handle: `#${name}`, + raw: channel, + }); + if (results.length >= limit) return results; + } + } + + return results; +} diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 6c4080755..a5e5415d2 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -176,7 +176,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } - const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? "allowlist") : "disabled"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = + !isDirectMessage && msteamsCfg + ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") + : "disabled"; const groupAllowFrom = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupAllowFrom ?? @@ -186,6 +190,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { !isDirectMessage && msteamsCfg ? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom] : []; + const teamId = activity.channelData?.team?.id; + const teamName = activity.channelData?.team?.name; + const channelName = activity.channelData?.channel?.name; + const channelGate = resolveMSTeamsRouteConfig({ + cfg: msteamsCfg, + teamId, + teamName, + conversationId, + channelName, + }); if (!isDirectMessage && msteamsCfg) { if (groupPolicy === "disabled") { @@ -196,25 +210,33 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } if (groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { - log.debug("dropping group message (groupPolicy: allowlist, no groupAllowFrom)", { + if (channelGate.allowlistConfigured && !channelGate.allowed) { + log.debug("dropping group message (not in team/channel allowlist)", { conversationId, }); return; } - const allowed = isMSTeamsGroupAllowed({ - groupPolicy, - allowFrom: effectiveGroupAllowFrom, - senderId, - senderName, - }); - if (!allowed) { - log.debug("dropping group message (not in groupAllowFrom)", { - sender: senderId, - label: senderName, + if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) { + log.debug("dropping group message (groupPolicy: allowlist, no allowlist)", { + conversationId, }); return; } + if (effectiveGroupAllowFrom.length > 0) { + const allowed = isMSTeamsGroupAllowed({ + groupPolicy, + allowFrom: effectiveGroupAllowFrom, + senderId, + senderName, + }); + if (!allowed) { + log.debug("dropping group message (not in groupAllowFrom)", { + sender: senderId, + label: senderName, + }); + return; + } + } } } @@ -244,7 +266,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { // Build conversation reference for proactive replies. const agent = activity.recipient; - const teamId = activity.channelData?.team?.id; const conversationRef: StoredConversationReference = { activityId: activity.id, user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId }, @@ -326,11 +347,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); const channelId = conversationId; - const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({ - cfg: msteamsCfg, - teamId, - conversationId: channelId, - }); + const { teamConfig, channelConfig } = channelGate; const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({ isDirectMessage, globalConfig: msteamsCfg, diff --git a/extensions/msteams/src/monitor.ts b/extensions/msteams/src/monitor.ts index d74f0ae94..0e2662fb7 100644 --- a/extensions/msteams/src/monitor.ts +++ b/extensions/msteams/src/monitor.ts @@ -9,11 +9,61 @@ import { formatUnknownError } from "./errors.js"; import type { MSTeamsAdapter } from "./messenger.js"; import { registerMSTeamsHandlers } from "./monitor-handler.js"; import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js"; +import { + resolveMSTeamsChannelAllowlist, + resolveMSTeamsUserAllowlist, +} from "./resolve-allowlist.js"; import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { resolveMSTeamsCredentials } from "./token.js"; const log = getChildLogger({ name: "msteams" }); +function mergeAllowlist(params: { + existing?: Array; + additions: string[]; +}): string[] { + const seen = new Set(); + const merged: string[] = []; + const push = (value: string) => { + const normalized = value.trim(); + if (!normalized) return; + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + merged.push(normalized); + }; + for (const entry of params.existing ?? []) { + push(String(entry)); + } + for (const entry of params.additions) { + push(entry); + } + return merged; +} + +function summarizeMapping( + label: string, + mapping: string[], + unresolved: string[], + runtime: RuntimeEnv, +) { + const lines: string[] = []; + if (mapping.length > 0) { + const sample = mapping.slice(0, 6); + const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : ""; + lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`); + } + if (unresolved.length > 0) { + const sample = unresolved.slice(0, 6); + const suffix = + unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : ""; + lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`); + } + if (lines.length > 0) { + runtime.log?.(lines.join("\n")); + } +} + export type MonitorMSTeamsOpts = { cfg: ClawdbotConfig; runtime?: RuntimeEnv; @@ -30,8 +80,8 @@ export type MonitorMSTeamsResult = { export async function monitorMSTeamsProvider( opts: MonitorMSTeamsOpts, ): Promise { - const cfg = opts.cfg; - const msteamsCfg = cfg.channels?.msteams; + let cfg = opts.cfg; + let msteamsCfg = cfg.channels?.msteams; if (!msteamsCfg?.enabled) { log.debug("msteams provider disabled"); return { app: null, shutdown: async () => {} }; @@ -52,6 +102,142 @@ export async function monitorMSTeamsProvider( }, }; + let allowFrom = msteamsCfg.allowFrom; + let groupAllowFrom = msteamsCfg.groupAllowFrom; + let teamsConfig = msteamsCfg.teams; + + const cleanAllowEntry = (entry: string) => + entry + .replace(/^(msteams|teams):/i, "") + .replace(/^user:/i, "") + .trim(); + + const resolveAllowlistUsers = async (label: string, entries: string[]) => { + if (entries.length === 0) return { additions: [], unresolved: [] }; + const resolved = await resolveMSTeamsUserAllowlist({ cfg, entries }); + const additions: string[] = []; + const unresolved: string[] = []; + for (const entry of resolved) { + if (entry.resolved && entry.id) { + additions.push(entry.id); + } else { + unresolved.push(entry.input); + } + } + const mapping = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => `${entry.input}→${entry.id}`); + summarizeMapping(label, mapping, unresolved, runtime); + return { additions, unresolved }; + }; + + try { + const allowEntries = + allowFrom?.map((entry) => cleanAllowEntry(String(entry))).filter( + (entry) => entry && entry !== "*", + ) ?? []; + if (allowEntries.length > 0) { + const { additions } = await resolveAllowlistUsers("msteams users", allowEntries); + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + } + + if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) { + const groupEntries = groupAllowFrom + .map((entry) => cleanAllowEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + if (groupEntries.length > 0) { + const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries); + groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions }); + } + } + + if (teamsConfig && Object.keys(teamsConfig).length > 0) { + const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = []; + for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) { + if (teamKey === "*") continue; + const channels = teamCfg?.channels ?? {}; + const channelKeys = Object.keys(channels).filter((key) => key !== "*"); + if (channelKeys.length === 0) { + entries.push({ input: teamKey, teamKey }); + continue; + } + for (const channelKey of channelKeys) { + entries.push({ + input: `${teamKey}/${channelKey}`, + teamKey, + channelKey, + }); + } + } + + if (entries.length > 0) { + const resolved = await resolveMSTeamsChannelAllowlist({ + cfg, + entries: entries.map((entry) => entry.input), + }); + const mapping: string[] = []; + const unresolved: string[] = []; + const nextTeams = { ...(teamsConfig ?? {}) }; + + resolved.forEach((entry, idx) => { + const source = entries[idx]; + if (!source) return; + const sourceTeam = teamsConfig?.[source.teamKey] ?? {}; + if (!entry.resolved || !entry.teamId) { + unresolved.push(entry.input); + return; + } + mapping.push( + entry.channelId + ? `${entry.input}→${entry.teamId}/${entry.channelId}` + : `${entry.input}→${entry.teamId}`, + ); + const existing = nextTeams[entry.teamId] ?? {}; + const mergedChannels = { + ...(sourceTeam.channels ?? {}), + ...(existing.channels ?? {}), + }; + const mergedTeam = { ...sourceTeam, ...existing, channels: mergedChannels }; + nextTeams[entry.teamId] = mergedTeam; + if (source.channelKey && entry.channelId) { + const sourceChannel = sourceTeam.channels?.[source.channelKey]; + if (sourceChannel) { + nextTeams[entry.teamId] = { + ...mergedTeam, + channels: { + ...mergedChannels, + [entry.channelId]: { + ...sourceChannel, + ...(mergedChannels?.[entry.channelId] ?? {}), + }, + }, + }; + } + } + }); + + teamsConfig = nextTeams; + summarizeMapping("msteams channels", mapping, unresolved, runtime); + } + } + } catch (err) { + runtime.log?.(`msteams resolve failed; using config entries. ${String(err)}`); + } + + msteamsCfg = { + ...msteamsCfg, + allowFrom, + groupAllowFrom, + teams: teamsConfig, + }; + cfg = { + ...cfg, + channels: { + ...cfg.channels, + msteams: msteamsCfg, + }, + }; + const port = msteamsCfg.webhook?.port ?? 3978; const textLimit = resolveTextChunkLimit(cfg, "msteams"); const MB = 1024 * 1024; diff --git a/extensions/msteams/src/onboarding.ts b/extensions/msteams/src/onboarding.ts index 54391cea0..f9348397e 100644 --- a/extensions/msteams/src/onboarding.ts +++ b/extensions/msteams/src/onboarding.ts @@ -7,9 +7,11 @@ import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { addWildcardAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js"; import { resolveMSTeamsCredentials } from "./token.js"; +import { resolveMSTeamsChannelAllowlist } from "./resolve-allowlist.js"; const channel = "msteams" as const; @@ -44,6 +46,66 @@ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise, +): ClawdbotConfig { + const baseTeams = cfg.channels?.msteams?.teams ?? {}; + const teams: Record }> = { ...baseTeams }; + for (const entry of entries) { + const teamKey = entry.teamKey; + if (!teamKey) continue; + const existing = teams[teamKey] ?? {}; + if (entry.channelKey) { + const channels = { ...(existing.channels ?? {}) }; + channels[entry.channelKey] = channels[entry.channelKey] ?? {}; + teams[teamKey] = { ...existing, channels }; + } else { + teams[teamKey] = existing; + } + } + return { + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + teams, + }, + }, + }; +} + +function parseMSTeamsTeamEntry(raw: string): { teamKey: string; channelKey?: string } | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const parts = trimmed.split("/"); + const teamPart = parts[0]?.trim(); + if (!teamPart) return null; + const channelPart = parts.length > 1 ? parts.slice(1).join("/").trim() : undefined; + const teamKey = teamPart.replace(/^team:/i, "").trim(); + const channelKey = channelPart ? channelPart.replace(/^#/, "").trim() : undefined; + return { teamKey, ...(channelKey ? { channelKey } : {}) }; +} + const dmPolicy: ChannelOnboardingDmPolicy = { label: "MS Teams", channel, @@ -184,6 +246,93 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { }; } + const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap( + ([teamKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) return [teamKey]; + return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`); + }, + ); + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "MS Teams channels", + currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist", + currentEntries, + placeholder: "Team Name/Channel Name, teamId/conversationId", + updatePrompt: Boolean(next.channels?.msteams?.teams), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMSTeamsGroupPolicy(next, accessConfig.policy); + } else { + let entries = accessConfig.entries + .map((entry) => parseMSTeamsTeamEntry(entry)) + .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; + if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) { + try { + const resolved = await resolveMSTeamsChannelAllowlist({ + cfg: next, + entries: accessConfig.entries, + }); + const resolvedChannels = resolved.filter( + (entry) => entry.resolved && entry.teamId && entry.channelId, + ); + const resolvedTeams = resolved.filter( + (entry) => entry.resolved && entry.teamId && !entry.channelId, + ); + const unresolved = resolved + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + + entries = [ + ...resolvedChannels.map((entry) => ({ + teamKey: entry.teamId as string, + channelKey: entry.channelId as string, + })), + ...resolvedTeams.map((entry) => ({ + teamKey: entry.teamId as string, + })), + ...unresolved + .map((entry) => parseMSTeamsTeamEntry(entry)) + .filter(Boolean), + ] as Array<{ teamKey: string; channelKey?: string }>; + + if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) { + const summary: string[] = []; + if (resolvedChannels.length > 0) { + summary.push( + `Resolved channels: ${resolvedChannels + .map((entry) => entry.channelId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (resolvedTeams.length > 0) { + summary.push( + `Resolved teams: ${resolvedTeams + .map((entry) => entry.teamId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (unresolved.length > 0) { + summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`); + } + await prompter.note(summary.join("\n"), "MS Teams channels"); + } + } catch (err) { + await prompter.note( + `Channel lookup failed; keeping entries as typed. ${String(err)}`, + "MS Teams channels", + ); + } + } + next = setMSTeamsGroupPolicy(next, "allowlist"); + next = setMSTeamsTeamsAllowlist(next, entries); + } + } + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, dmPolicy, diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index 260a3b1ef..ecd2c9dfe 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -29,6 +29,8 @@ describe("msteams policy", () => { expect(res.teamConfig?.requireMention).toBe(false); expect(res.channelConfig?.requireMention).toBe(true); + expect(res.allowlistConfigured).toBe(true); + expect(res.allowed).toBe(true); }); it("returns undefined configs when teamId is missing", () => { @@ -43,6 +45,32 @@ describe("msteams policy", () => { }); expect(res.teamConfig).toBeUndefined(); expect(res.channelConfig).toBeUndefined(); + expect(res.allowlistConfigured).toBe(true); + expect(res.allowed).toBe(false); + }); + + it("matches team and channel by name", () => { + const cfg: MSTeamsConfig = { + teams: { + "My Team": { + requireMention: true, + channels: { + "General Chat": { requireMention: false }, + }, + }, + }, + }; + + const res = resolveMSTeamsRouteConfig({ + cfg, + teamName: "My Team", + channelName: "General Chat", + conversationId: "ignored", + }); + + expect(res.teamConfig?.requireMention).toBe(true); + expect(res.channelConfig?.requireMention).toBe(false); + expect(res.allowed).toBe(true); }); }); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index dea2a3a18..99563befd 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -9,19 +9,73 @@ import type { export type MSTeamsResolvedRouteConfig = { teamConfig?: MSTeamsTeamConfig; channelConfig?: MSTeamsChannelConfig; + allowlistConfigured: boolean; + allowed: boolean; + teamKey?: string; + channelKey?: string; }; export function resolveMSTeamsRouteConfig(params: { cfg?: MSTeamsConfig; teamId?: string | null | undefined; + teamName?: string | null | undefined; conversationId?: string | null | undefined; + channelName?: string | null | undefined; }): MSTeamsResolvedRouteConfig { const teamId = params.teamId?.trim(); + const teamName = params.teamName?.trim(); const conversationId = params.conversationId?.trim(); - const teamConfig = teamId ? params.cfg?.teams?.[teamId] : undefined; - const channelConfig = - teamConfig && conversationId ? teamConfig.channels?.[conversationId] : undefined; - return { teamConfig, channelConfig }; + const channelName = params.channelName?.trim(); + const teams = params.cfg?.teams ?? {}; + const teamKeys = Object.keys(teams); + const allowlistConfigured = teamKeys.length > 0; + + const normalize = (value: string) => + value + .trim() + .toLowerCase() + .replace(/^#/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + let teamKey: string | undefined; + if (teamId && teams[teamId]) teamKey = teamId; + if (!teamKey && teamName) { + const slug = normalize(teamName); + if (slug) { + teamKey = teamKeys.find((key) => normalize(key) === slug); + } + } + if (!teamKey && teams["*"]) teamKey = "*"; + + const teamConfig = teamKey ? teams[teamKey] : undefined; + const channels = teamConfig?.channels ?? {}; + const channelKeys = Object.keys(channels); + + let channelKey: string | undefined; + if (conversationId && channels[conversationId]) channelKey = conversationId; + if (!channelKey && channelName) { + const slug = normalize(channelName); + if (slug) { + channelKey = channelKeys.find((key) => normalize(key) === slug); + } + } + if (!channelKey && channels["*"]) channelKey = "*"; + const channelConfig = channelKey ? channels[channelKey] : undefined; + const channelAllowlistConfigured = channelKeys.length > 0; + + const allowed = !allowlistConfigured + ? true + : Boolean(teamConfig) && (!channelAllowlistConfigured || Boolean(channelConfig)); + + return { + teamConfig, + channelConfig, + allowlistConfigured, + allowed, + teamKey, + channelKey, + }; } export type MSTeamsReplyPolicy = { diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts new file mode 100644 index 000000000..5ba69d9d1 --- /dev/null +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -0,0 +1,223 @@ +import { GRAPH_ROOT } from "./attachments/shared.js"; +import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +type GraphUser = { + id?: string; + displayName?: string; + userPrincipalName?: string; + mail?: string; +}; + +type GraphGroup = { + id?: string; + displayName?: string; +}; + +type GraphChannel = { + id?: string; + displayName?: string; +}; + +type GraphResponse = { value?: T[] }; + +export type MSTeamsChannelResolution = { + input: string; + resolved: boolean; + teamId?: string; + teamName?: string; + channelId?: string; + channelName?: string; + note?: string; +}; + +export type MSTeamsUserResolution = { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; +}; + +function readAccessToken(value: unknown): string | null { + if (typeof value === "string") return value; + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim() ?? ""; +} + +function escapeOData(value: string): string { + return value.replace(/'/g, "''"); +} + +async function fetchGraphJson(params: { + token: string; + path: string; + headers?: Record; +}): Promise { + const res = await fetch(`${GRAPH_ROOT}${params.path}`, { + headers: { + Authorization: `Bearer ${params.token}`, + ...(params.headers ?? {}), + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +async function resolveGraphToken(cfg: unknown): Promise { + const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams); + if (!creds) throw new Error("MS Teams credentials missing"); + const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); + const tokenProvider = new sdk.MsalTokenProvider(authConfig); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + const accessToken = readAccessToken(token); + if (!accessToken) throw new Error("MS Teams graph token unavailable"); + return accessToken; +} + +function parseTeamChannelInput(raw: string): { team?: string; channel?: string } { + const trimmed = raw.trim(); + if (!trimmed) return {}; + const parts = trimmed.split("/"); + const team = parts[0]?.trim(); + const channel = parts.length > 1 ? parts.slice(1).join("/").trim() : undefined; + return { team: team || undefined, channel: channel || undefined }; +} + +async function listTeamsByName(token: string, query: string): Promise { + const escaped = escapeOData(query); + const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`; + const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +async function listChannelsForTeam(token: string, teamId: string): Promise { + const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +export async function resolveMSTeamsChannelAllowlist(params: { + cfg: unknown; + entries: string[]; +}): Promise { + const token = await resolveGraphToken(params.cfg); + const results: MSTeamsChannelResolution[] = []; + + for (const input of params.entries) { + const { team, channel } = parseTeamChannelInput(input); + if (!team) { + results.push({ input, resolved: false }); + continue; + } + const teams = + /^[0-9a-fA-F-]{16,}$/.test(team) ? [{ id: team, displayName: team }] : await listTeamsByName(token, team); + if (teams.length === 0) { + results.push({ input, resolved: false, note: "team not found" }); + continue; + } + const teamMatch = teams[0]; + const teamId = teamMatch.id?.trim(); + const teamName = teamMatch.displayName?.trim() || team; + if (!teamId) { + results.push({ input, resolved: false, note: "team id missing" }); + continue; + } + if (!channel) { + results.push({ + input, + resolved: true, + teamId, + teamName, + note: teams.length > 1 ? "multiple teams; chose first" : undefined, + }); + continue; + } + const channels = await listChannelsForTeam(token, teamId); + const channelMatch = + channels.find((item) => item.id === channel) ?? + channels.find( + (item) => item.displayName?.toLowerCase() === channel.toLowerCase(), + ) ?? + channels.find( + (item) => item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""), + ); + if (!channelMatch?.id) { + results.push({ input, resolved: false, note: "channel not found" }); + continue; + } + results.push({ + input, + resolved: true, + teamId, + teamName, + channelId: channelMatch.id, + channelName: channelMatch.displayName ?? channel, + note: channels.length > 1 ? "multiple channels; chose first" : undefined, + }); + } + + return results; +} + +export async function resolveMSTeamsUserAllowlist(params: { + cfg: unknown; + entries: string[]; +}): Promise { + const token = await resolveGraphToken(params.cfg); + const results: MSTeamsUserResolution[] = []; + + for (const input of params.entries) { + const query = normalizeQuery(input); + if (!query) { + results.push({ input, resolved: false }); + continue; + } + if (/^[0-9a-fA-F-]{16,}$/.test(query)) { + results.push({ input, resolved: true, id: query }); + continue; + } + let users: GraphUser[] = []; + if (query.includes("@")) { + const escaped = escapeOData(query); + const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; + const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; + const res = await fetchGraphJson>({ token, path }); + users = res.value ?? []; + } else { + const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`; + const res = await fetchGraphJson>({ + token, + path, + headers: { ConsistencyLevel: "eventual" }, + }); + users = res.value ?? []; + } + const match = users[0]; + if (!match?.id) { + results.push({ input, resolved: false }); + continue; + } + results.push({ + input, + resolved: true, + id: match.id, + name: match.displayName ?? undefined, + note: users.length > 1 ? "multiple matches; chose first" : undefined, + }); + } + + return results; +} diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 8cab366ec..557c92447 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -324,6 +324,73 @@ export const zalouserPlugin: ChannelPlugin = { return sliced as ChannelDirectoryEntry[]; }, }, + resolver: { + resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => { + const results = []; + for (const input of inputs) { + const trimmed = input.trim(); + if (!trimmed) { + results.push({ input, resolved: false, note: "empty input" }); + continue; + } + if (/^\d+$/.test(trimmed)) { + results.push({ input, resolved: true, id: trimmed }); + continue; + } + try { + const account = resolveZalouserAccountSync({ + cfg: cfg as CoreConfig, + accountId: accountId ?? DEFAULT_ACCOUNT_ID, + }); + const args = + kind === "user" + ? trimmed + ? ["friend", "find", trimmed] + : ["friend", "list", "-j"] + : ["group", "list", "-j"]; + const result = await runZca(args, { profile: account.profile, timeout: 15000 }); + if (!result.ok) throw new Error(result.stderr || "zca lookup failed"); + if (kind === "user") { + const parsed = parseJsonOutput(result.stdout) ?? []; + const matches = Array.isArray(parsed) + ? parsed.map((f) => ({ + id: String(f.userId), + name: f.displayName ?? undefined, + })) + : []; + const best = matches[0]; + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } else { + const parsed = parseJsonOutput(result.stdout) ?? []; + const matches = Array.isArray(parsed) + ? parsed.map((g) => ({ + id: String(g.groupId), + name: g.name ?? undefined, + })) + : []; + const best = matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0]; + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } + } catch (err) { + runtime.error?.(`zalouser resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + } + return results; + }, + }, pairing: { idLabel: "zalouserUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""), diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index b572a08a9..9df8ad0a1 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -9,8 +9,14 @@ import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-co import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js"; import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js"; import { sendMessageZalouser } from "./send.js"; -import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js"; -import { runZcaStreaming } from "./zca.js"; +import type { + CoreConfig, + ResolvedZalouserAccount, + ZcaFriend, + ZcaGroup, + ZcaMessage, +} from "./types.js"; +import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js"; export type ZalouserMonitorOptions = { account: ResolvedZalouserAccount; @@ -26,6 +32,71 @@ export type ZalouserMonitorResult = { const ZALOUSER_TEXT_LIMIT = 2000; +function mergeAllowlist(params: { + existing?: Array; + additions: string[]; +}): string[] { + const seen = new Set(); + const merged: string[] = []; + const push = (value: string) => { + const normalized = value.trim(); + if (!normalized) return; + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + merged.push(normalized); + }; + for (const entry of params.existing ?? []) { + push(String(entry)); + } + for (const entry of params.additions) { + push(entry); + } + return merged; +} + +function summarizeMapping( + label: string, + mapping: string[], + unresolved: string[], + runtime: RuntimeEnv, +) { + const lines: string[] = []; + if (mapping.length > 0) { + const sample = mapping.slice(0, 6); + const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : ""; + lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`); + } + if (unresolved.length > 0) { + const sample = unresolved.slice(0, 6); + const suffix = + unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : ""; + lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`); + } + if (lines.length > 0) { + runtime.log?.(lines.join("\n")); + } +} + +function normalizeZalouserEntry(entry: string): string { + return entry.replace(/^(zalouser|zlu):/i, "").trim(); +} + +function buildNameIndex( + items: T[], + nameFn: (item: T) => string | undefined, +): Map { + const index = new Map(); + for (const item of items) { + const name = nameFn(item)?.trim().toLowerCase(); + if (!name) continue; + const list = index.get(name) ?? []; + list.push(item); + index.set(name, list); + } + return index; +} + function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void { if (deps.shouldLogVerbose()) { runtime.log(`[zalouser] ${message}`); @@ -41,6 +112,39 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { }); } +function normalizeGroupSlug(raw?: string | null): string { + const trimmed = raw?.trim().toLowerCase() ?? ""; + if (!trimmed) return ""; + return trimmed + .replace(/^#/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function isGroupAllowed(params: { + groupId: string; + groupName?: string | null; + groups: Record; +}): boolean { + const groups = params.groups ?? {}; + const keys = Object.keys(groups); + if (keys.length === 0) return false; + const candidates = [ + params.groupId, + `group:${params.groupId}`, + params.groupName ?? "", + normalizeGroupSlug(params.groupName ?? ""), + ].filter(Boolean); + for (const candidate of candidates) { + const entry = groups[candidate]; + if (!entry) continue; + return entry.allow !== false && entry.enabled !== false; + } + const wildcard = groups["*"]; + if (wildcard) return wildcard.allow !== false && wildcard.enabled !== false; + return false; +} + function startZcaListener( runtime: RuntimeEnv, profile: string, @@ -106,8 +210,26 @@ async function processMessage( const isGroup = metadata?.isGroup ?? false; const senderId = metadata?.fromId ?? threadId; const senderName = metadata?.senderName ?? ""; + const groupName = metadata?.threadName ?? ""; const chatId = threadId; + const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const groups = account.config.groups ?? {}; + if (isGroup) { + if (groupPolicy === "disabled") { + logVerbose(deps, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`); + return; + } + if (groupPolicy === "allowlist") { + const allowed = isGroupAllowed({ groupId: chatId, groupName, groups }); + if (!allowed) { + logVerbose(deps, runtime, `zalouser: drop group ${chatId} (not allowlisted)`); + return; + } + } + } + const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); const rawBody = content.trim(); @@ -194,11 +316,10 @@ async function processMessage( }, }); - const rawBody = content.trim(); - const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; - const body = deps.formatAgentEnvelope({ - channel: "Zalo Personal", - from: fromLabel, + const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; + const body = deps.formatAgentEnvelope({ + channel: "Zalo Personal", + from: fromLabel, timestamp: timestamp ? timestamp * 1000 : undefined, body: rawBody, }); @@ -301,7 +422,8 @@ async function deliverZalouserReply(params: { export async function monitorZalouserProvider( options: ZalouserMonitorOptions, ): Promise { - const { account, config, abortSignal, statusSink, runtime } = options; + let { account, config } = options; + const { abortSignal, statusSink, runtime } = options; const deps = await loadCoreChannelDeps(); let stopped = false; @@ -309,6 +431,92 @@ export async function monitorZalouserProvider( let restartTimer: ReturnType | null = null; let resolveRunning: (() => void) | null = null; + try { + const profile = account.profile; + const allowFromEntries = (account.config.allowFrom ?? []) + .map((entry) => normalizeZalouserEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + + if (allowFromEntries.length > 0) { + const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 }); + if (result.ok) { + const friends = parseJsonOutput(result.stdout) ?? []; + const byName = buildNameIndex(friends, (friend) => friend.displayName); + const additions: string[] = []; + const mapping: string[] = []; + const unresolved: string[] = []; + for (const entry of allowFromEntries) { + if (/^\d+$/.test(entry)) { + additions.push(entry); + continue; + } + const matches = byName.get(entry.toLowerCase()) ?? []; + const match = matches[0]; + const id = match?.userId ? String(match.userId) : undefined; + if (id) { + additions.push(id); + mapping.push(`${entry}→${id}`); + } else { + unresolved.push(entry); + } + } + const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions }); + account = { + ...account, + config: { + ...account.config, + allowFrom, + }, + }; + summarizeMapping("zalouser users", mapping, unresolved, runtime); + } else { + runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`); + } + } + + const groupsConfig = account.config.groups ?? {}; + const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*"); + if (groupKeys.length > 0) { + const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 }); + if (result.ok) { + const groups = parseJsonOutput(result.stdout) ?? []; + const byName = buildNameIndex(groups, (group) => group.name); + const mapping: string[] = []; + const unresolved: string[] = []; + const nextGroups = { ...groupsConfig }; + for (const entry of groupKeys) { + const cleaned = normalizeZalouserEntry(entry); + if (/^\d+$/.test(cleaned)) { + if (!nextGroups[cleaned]) nextGroups[cleaned] = groupsConfig[entry]; + mapping.push(`${entry}→${cleaned}`); + continue; + } + const matches = byName.get(cleaned.toLowerCase()) ?? []; + const match = matches[0]; + const id = match?.groupId ? String(match.groupId) : undefined; + if (id) { + if (!nextGroups[id]) nextGroups[id] = groupsConfig[entry]; + mapping.push(`${entry}→${id}`); + } else { + unresolved.push(entry); + } + } + account = { + ...account, + config: { + ...account.config, + groups: nextGroups, + }, + }; + summarizeMapping("zalouser groups", mapping, unresolved, runtime); + } else { + runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`); + } + } + } catch (err) { + runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`); + } + const stop = () => { stopped = true; if (restartTimer) { diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 61634df19..51359b5a4 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -1,5 +1,6 @@ import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { listZalouserAccountIds, @@ -8,8 +9,8 @@ import { normalizeAccountId, checkZcaAuthenticated, } from "./accounts.js"; -import { runZcaInteractive, checkZcaInstalled } from "./zca.js"; -import { DEFAULT_ACCOUNT_ID, type CoreConfig } from "./types.js"; +import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js"; +import { DEFAULT_ACCOUNT_ID, type CoreConfig, type ZcaGroup } from "./types.js"; const channel = "zalouser" as const; @@ -113,6 +114,115 @@ async function promptZalouserAllowFrom(params: { } as CoreConfig; } +function setZalouserGroupPolicy( + cfg: CoreConfig, + accountId: string, + groupPolicy: "open" | "allowlist" | "disabled", +): CoreConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + groupPolicy, + }, + }, + } as CoreConfig; + } + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + accounts: { + ...(cfg.channels?.zalouser?.accounts ?? {}), + [accountId]: { + ...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}), + enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, + groupPolicy, + }, + }, + }, + }, + } as CoreConfig; +} + +function setZalouserGroupAllowlist( + cfg: CoreConfig, + accountId: string, + groupKeys: string[], +): CoreConfig { + const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + groups, + }, + }, + } as CoreConfig; + } + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + accounts: { + ...(cfg.channels?.zalouser?.accounts ?? {}), + [accountId]: { + ...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}), + enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, + groups, + }, + }, + }, + }, + } as CoreConfig; +} + +async function resolveZalouserGroups(params: { + cfg: CoreConfig; + accountId: string; + entries: string[]; +}): Promise> { + const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId }); + const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 }); + if (!result.ok) throw new Error(result.stderr || "Failed to list groups"); + const groups = (parseJsonOutput(result.stdout) ?? []).filter( + (group) => Boolean(group.groupId), + ); + const byName = new Map(); + for (const group of groups) { + const name = group.name?.trim().toLowerCase(); + if (!name) continue; + const list = byName.get(name) ?? []; + list.push(group); + byName.set(name, list); + } + + return params.entries.map((input) => { + const trimmed = input.trim(); + if (!trimmed) return { input, resolved: false }; + if (/^\d+$/.test(trimmed)) return { input, resolved: true, id: trimmed }; + const matches = byName.get(trimmed.toLowerCase()) ?? []; + const match = matches[0]; + return match?.groupId + ? { input, resolved: true, id: String(match.groupId) } + : { input, resolved: false }; + }); +} + async function promptAccountId(params: { cfg: CoreConfig; prompter: WizardPrompter; @@ -307,6 +417,61 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { }); } + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Zalo groups", + currentPolicy: account.config.groupPolicy ?? "open", + currentEntries: Object.keys(account.config.groups ?? {}), + placeholder: "Family, Work, 123456789", + updatePrompt: Boolean(account.config.groups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setZalouserGroupPolicy(next, accountId, accessConfig.policy); + } else { + let keys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolved = await resolveZalouserGroups({ + cfg: next, + accountId, + entries: accessConfig.entries, + }); + const resolvedIds = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + keys = [ + ...resolvedIds, + ...unresolved.map((entry) => entry.trim()).filter(Boolean), + ]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Zalo groups", + ); + } + } catch (err) { + await prompter.note( + `Group lookup failed; keeping entries as typed. ${String(err)}`, + "Zalo groups", + ); + } + } + next = setZalouserGroupPolicy(next, accountId, "allowlist"); + next = setZalouserGroupAllowlist(next, accountId, keys); + } + } + return { cfg: next, accountId }; }, }; diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index db787823b..441d26144 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -77,6 +77,8 @@ export type ZalouserAccountConfig = { profile?: string; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; + groupPolicy?: "open" | "allowlist" | "disabled"; + groups?: Record; messagePrefix?: string; }; @@ -87,6 +89,8 @@ export type ZalouserConfig = { defaultAccount?: string; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; + groupPolicy?: "open" | "allowlist" | "disabled"; + groups?: Record; messagePrefix?: string; accounts?: Record; }; diff --git a/src/channels/plugins/discord.ts b/src/channels/plugins/discord.ts index eb9a65a8f..30fa35485 100644 --- a/src/channels/plugins/discord.ts +++ b/src/channels/plugins/discord.ts @@ -9,11 +9,9 @@ import { collectDiscordAuditChannelIds, } from "../../discord/audit.js"; import { probeDiscord } from "../../discord/probe.js"; -import { - listGuildChannelsDiscord, - sendMessageDiscord, - sendPollDiscord, -} from "../../discord/send.js"; +import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; +import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; +import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; import { shouldLogVerbose } from "../../globals.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { getChatChannelMeta } from "../registry.js"; @@ -42,6 +40,10 @@ import { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, } from "./directory-config.js"; +import { + listDiscordDirectoryGroupsLive, + listDiscordDirectoryPeersLive, +} from "../../discord/directory-live.js"; const meta = getChatChannelMeta("discord"); @@ -123,9 +125,10 @@ export const discordPlugin: ChannelPlugin = { normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), }; }, - collectWarnings: ({ account }) => { + collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; const channelAllowlistConfigured = guildsConfigured; @@ -165,29 +168,41 @@ export const discordPlugin: ChannelPlugin = { self: async () => null, listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), - listGroupsLive: async ({ cfg, accountId, query, limit }) => { + listPeersLive: async (params) => listDiscordDirectoryPeersLive(params), + listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params), + }, + resolver: { + resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const account = resolveDiscordAccount({ cfg, accountId }); - const q = query?.trim().toLowerCase() || ""; - const guildIds = Object.keys(account.config.guilds ?? {}).filter((id) => /^\d+$/.test(id)); - const rows: Array<{ kind: "group"; id: string; name?: string; raw?: unknown }> = []; - for (const guildId of guildIds) { - const channels = await listGuildChannelsDiscord(guildId, { - accountId: account.accountId, - }); - for (const channel of channels) { - const name = typeof channel.name === "string" ? channel.name : undefined; - if (q && name && !name.toLowerCase().includes(q)) continue; - rows.push({ - kind: "group", - id: `channel:${channel.id}`, - name: name ?? undefined, - raw: channel, - }); - } + const token = account.token?.trim(); + if (!token) { + return inputs.map((input) => ({ + input, + resolved: false, + note: "missing Discord token", + })); } - const filtered = q ? rows.filter((row) => row.name?.toLowerCase().includes(q)) : rows; - const limited = typeof limit === "number" && limit > 0 ? filtered.slice(0, limit) : filtered; - return limited; + if (kind === "group") { + const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.channelId ?? entry.guildId, + name: + entry.channelName ?? + entry.guildName ?? + (entry.guildId && !entry.channelId ? entry.guildId : undefined), + note: entry.note, + })); + } + const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); }, }, actions: discordMessageActions, diff --git a/src/channels/plugins/imessage.ts b/src/channels/plugins/imessage.ts index 9b88d7538..ca0d207fc 100644 --- a/src/channels/plugins/imessage.ts +++ b/src/channels/plugins/imessage.ts @@ -95,8 +95,9 @@ export const imessagePlugin: ChannelPlugin = { approveHint: formatPairingApproveHint("imessage"), }; }, - collectWarnings: ({ account }) => { - const groupPolicy = account.config.groupPolicy ?? "allowlist"; + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; return [ `- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`, diff --git a/src/channels/plugins/onboarding/channel-access.ts b/src/channels/plugins/onboarding/channel-access.ts new file mode 100644 index 000000000..e5f11a421 --- /dev/null +++ b/src/channels/plugins/onboarding/channel-access.ts @@ -0,0 +1,93 @@ +import type { WizardPrompter } from "../../../wizard/prompts.js"; + +export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; + +export function parseAllowlistEntries(raw: string): string[] { + return String(raw ?? "") + .split(/[,\n]/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function formatAllowlistEntries(entries: string[]): string { + return entries.map((entry) => entry.trim()).filter(Boolean).join(", "); +} + +export async function promptChannelAccessPolicy(params: { + prompter: WizardPrompter; + label: string; + currentPolicy?: ChannelAccessPolicy; + allowOpen?: boolean; + allowDisabled?: boolean; +}): Promise { + const options: Array<{ value: ChannelAccessPolicy; label: string }> = [ + { value: "allowlist", label: "Allowlist (recommended)" }, + ]; + if (params.allowOpen !== false) { + options.push({ value: "open", label: "Open (allow all channels)" }); + } + if (params.allowDisabled !== false) { + options.push({ value: "disabled", label: "Disabled (block all channels)" }); + } + const initialValue = params.currentPolicy ?? "allowlist"; + return (await params.prompter.select({ + message: `${params.label} access`, + options, + initialValue, + })) as ChannelAccessPolicy; +} + +export async function promptChannelAllowlist(params: { + prompter: WizardPrompter; + label: string; + currentEntries?: string[]; + placeholder?: string; +}): Promise { + const initialValue = + params.currentEntries && params.currentEntries.length > 0 + ? formatAllowlistEntries(params.currentEntries) + : undefined; + const raw = await params.prompter.text({ + message: `${params.label} allowlist (comma-separated)`, + placeholder: params.placeholder, + initialValue, + }); + return parseAllowlistEntries(raw); +} + +export async function promptChannelAccessConfig(params: { + prompter: WizardPrompter; + label: string; + currentPolicy?: ChannelAccessPolicy; + currentEntries?: string[]; + placeholder?: string; + allowOpen?: boolean; + allowDisabled?: boolean; + defaultPrompt?: boolean; + updatePrompt?: boolean; +}): Promise<{ policy: ChannelAccessPolicy; entries: string[] } | null> { + const hasEntries = (params.currentEntries ?? []).length > 0; + const shouldPrompt = params.defaultPrompt ?? !hasEntries; + const wants = await params.prompter.confirm({ + message: params.updatePrompt + ? `Update ${params.label} access?` + : `Configure ${params.label} access?`, + initialValue: shouldPrompt, + }); + if (!wants) return null; + const policy = await promptChannelAccessPolicy({ + prompter: params.prompter, + label: params.label, + currentPolicy: params.currentPolicy, + allowOpen: params.allowOpen, + allowDisabled: params.allowDisabled, + }); + if (policy !== "allowlist") return { policy, entries: [] }; + const entries = await promptChannelAllowlist({ + prompter: params.prompter, + label: params.label, + currentEntries: params.currentEntries, + placeholder: params.placeholder, + }); + return { policy, entries }; +} diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts index 2caa0a474..ccaf07e2d 100644 --- a/src/channels/plugins/onboarding/discord.ts +++ b/src/channels/plugins/onboarding/discord.ts @@ -5,10 +5,13 @@ import { resolveDefaultDiscordAccountId, resolveDiscordAccount, } from "../../../discord/accounts.js"; +import { normalizeDiscordSlug } from "../../../discord/monitor/allow-list.js"; +import { resolveDiscordChannelAllowlist } from "../../../discord/resolve-channels.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; +import { promptChannelAccessConfig } from "./channel-access.js"; import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; const channel = "discord" as const; @@ -46,6 +49,103 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { ); } +function setDiscordGroupPolicy( + cfg: ClawdbotConfig, + accountId: string, + groupPolicy: "open" | "allowlist" | "disabled", +): ClawdbotConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + discord: { + ...cfg.channels?.discord, + enabled: true, + groupPolicy, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + discord: { + ...cfg.channels?.discord, + enabled: true, + accounts: { + ...cfg.channels?.discord?.accounts, + [accountId]: { + ...cfg.channels?.discord?.accounts?.[accountId], + enabled: cfg.channels?.discord?.accounts?.[accountId]?.enabled ?? true, + groupPolicy, + }, + }, + }, + }, + }; +} + +function setDiscordGuildChannelAllowlist( + cfg: ClawdbotConfig, + accountId: string, + entries: Array<{ + guildKey: string; + channelKey?: string; + }>, +): ClawdbotConfig { + const baseGuilds = + accountId === DEFAULT_ACCOUNT_ID + ? (cfg.channels?.discord?.guilds ?? {}) + : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); + const guilds: Record }> = { + ...baseGuilds, + }; + for (const entry of entries) { + const guildKey = entry.guildKey || "*"; + const existing = guilds[guildKey] ?? {}; + if (entry.channelKey) { + const channels = { ...(existing.channels ?? {}) }; + channels[entry.channelKey] = { allow: true }; + guilds[guildKey] = { ...existing, channels }; + } else { + guilds[guildKey] = existing; + } + } + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + discord: { + ...cfg.channels?.discord, + enabled: true, + guilds, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + discord: { + ...cfg.channels?.discord, + enabled: true, + accounts: { + ...cfg.channels?.discord?.accounts, + [accountId]: { + ...cfg.channels?.discord?.accounts?.[accountId], + enabled: cfg.channels?.discord?.accounts?.[accountId]?.enabled ?? true, + guilds, + }, + }, + }, + }, + }; +} + const dmPolicy: ChannelOnboardingDmPolicy = { label: "Discord", channel, @@ -174,6 +274,91 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = { } } + const currentEntries = Object.entries(resolvedAccount.config.guilds ?? {}).flatMap( + ([guildKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) return [guildKey]; + return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); + }, + ); + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Discord channels", + currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist", + currentEntries, + placeholder: "My Server/#general, guildId/channelId, #support", + updatePrompt: Boolean(resolvedAccount.config.guilds), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setDiscordGroupPolicy(next, discordAccountId, accessConfig.policy); + } else { + const accountWithTokens = resolveDiscordAccount({ + cfg: next, + accountId: discordAccountId, + }); + let resolved = accessConfig.entries.map((input) => ({ input, resolved: false })); + if (accountWithTokens.token && accessConfig.entries.length > 0) { + try { + resolved = await resolveDiscordChannelAllowlist({ + token: accountWithTokens.token, + entries: accessConfig.entries, + }); + const resolvedChannels = resolved.filter( + (entry) => entry.resolved && entry.channelId, + ); + const resolvedGuilds = resolved.filter( + (entry) => entry.resolved && entry.guildId && !entry.channelId, + ); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + if (resolvedChannels.length > 0 || resolvedGuilds.length > 0 || unresolved.length > 0) { + const summary: string[] = []; + if (resolvedChannels.length > 0) { + summary.push( + `Resolved channels: ${resolvedChannels + .map((entry) => entry.channelId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (resolvedGuilds.length > 0) { + summary.push( + `Resolved guilds: ${resolvedGuilds + .map((entry) => entry.guildId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (unresolved.length > 0) { + summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`); + } + await prompter.note(summary.join("\n"), "Discord channels"); + } + } catch (err) { + await prompter.note( + `Channel lookup failed; keeping entries as typed. ${String(err)}`, + "Discord channels", + ); + } + } + const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; + for (const entry of resolved) { + const guildKey = + entry.guildId ?? + (entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ?? + "*"; + const channelKey = + entry.channelId ?? + (entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined); + if (!channelKey && guildKey === "*") continue; + allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) }); + } + next = setDiscordGroupPolicy(next, discordAccountId, "allowlist"); + next = setDiscordGuildChannelAllowlist(next, discordAccountId, allowlistEntries); + } + } + return { cfg: next, accountId: discordAccountId }; }, dmPolicy, diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index a51d4e37d..a4b1c4925 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -6,9 +6,11 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, } from "../../../slack/accounts.js"; +import { resolveSlackChannelAllowlist } from "../../../slack/resolve-channels.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; +import { promptChannelAccessConfig } from "./channel-access.js"; import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; const channel = "slack" as const; @@ -121,6 +123,85 @@ async function noteSlackTokenHelp(prompter: WizardPrompter, botName: string): Pr ); } +function setSlackGroupPolicy( + cfg: ClawdbotConfig, + accountId: string, + groupPolicy: "open" | "allowlist" | "disabled", +): ClawdbotConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + slack: { + ...cfg.channels?.slack, + enabled: true, + groupPolicy, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + slack: { + ...cfg.channels?.slack, + enabled: true, + accounts: { + ...cfg.channels?.slack?.accounts, + [accountId]: { + ...cfg.channels?.slack?.accounts?.[accountId], + enabled: cfg.channels?.slack?.accounts?.[accountId]?.enabled ?? true, + groupPolicy, + }, + }, + }, + }, + }; +} + +function setSlackChannelAllowlist( + cfg: ClawdbotConfig, + accountId: string, + channelKeys: string[], +): ClawdbotConfig { + const channels = Object.fromEntries( + channelKeys.map((key) => [key, { allow: true }]), + ); + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + slack: { + ...cfg.channels?.slack, + enabled: true, + channels, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + slack: { + ...cfg.channels?.slack, + enabled: true, + accounts: { + ...cfg.channels?.slack?.accounts, + [accountId]: { + ...cfg.channels?.slack?.accounts?.[accountId], + enabled: cfg.channels?.slack?.accounts?.[accountId]?.enabled ?? true, + channels, + }, + }, + }, + }, + }; +} + const dmPolicy: ChannelOnboardingDmPolicy = { label: "Slack", channel, @@ -284,6 +365,68 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = { } } + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Slack channels", + currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist", + currentEntries: Object.entries(resolvedAccount.config.channels ?? {}) + .filter(([, value]) => value?.allow !== false && value?.enabled !== false) + .map(([key]) => key), + placeholder: "#general, #private, C123", + updatePrompt: Boolean(resolvedAccount.config.channels), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setSlackGroupPolicy(next, slackAccountId, accessConfig.policy); + } else { + let keys = accessConfig.entries; + const accountWithTokens = resolveSlackAccount({ + cfg: next, + accountId: slackAccountId, + }); + if (accountWithTokens.botToken && accessConfig.entries.length > 0) { + try { + const resolved = await resolveSlackChannelAllowlist({ + token: accountWithTokens.botToken, + entries: accessConfig.entries, + }); + const resolvedKeys = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + keys = [ + ...resolvedKeys, + ...unresolved.map((entry) => entry.trim()).filter(Boolean), + ]; + if (resolvedKeys.length > 0 || unresolved.length > 0) { + await prompter.note( + [ + resolvedKeys.length > 0 + ? `Resolved: ${resolvedKeys.join(", ")}` + : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Slack channels", + ); + } + } catch (err) { + await prompter.note( + `Channel lookup failed; keeping entries as typed. ${String(err)}`, + "Slack channels", + ); + } + } + next = setSlackGroupPolicy(next, slackAccountId, "allowlist"); + next = setSlackChannelAllowlist(next, slackAccountId, keys); + } + } + return { cfg: next, accountId: slackAccountId }; }, dmPolicy, diff --git a/src/channels/plugins/signal.ts b/src/channels/plugins/signal.ts index c404c63bb..60469d599 100644 --- a/src/channels/plugins/signal.ts +++ b/src/channels/plugins/signal.ts @@ -108,8 +108,9 @@ export const signalPlugin: ChannelPlugin = { normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), }; }, - collectWarnings: ({ account }) => { - const groupPolicy = account.config.groupPolicy ?? "allowlist"; + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; return [ `- Signal groups: groupPolicy="open" allows any member to trigger the bot. Set channels.signal.groupPolicy="allowlist" + channels.signal.groupAllowFrom to restrict senders.`, diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index dd70421d3..aeb593645 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -9,6 +9,8 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, } from "../../slack/accounts.js"; +import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js"; +import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js"; import { probeSlack } from "../../slack/probe.js"; import { sendMessageSlack } from "../../slack/send.js"; import { getChatChannelMeta } from "../registry.js"; @@ -32,6 +34,10 @@ import { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, } from "./directory-config.js"; +import { + listSlackDirectoryGroupsLive, + listSlackDirectoryPeersLive, +} from "../../slack/directory-live.js"; const meta = getChatChannelMeta("slack"); @@ -138,9 +144,10 @@ export const slackPlugin: ChannelPlugin = { normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), }; }, - collectWarnings: ({ account }) => { + collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; @@ -190,6 +197,39 @@ export const slackPlugin: ChannelPlugin = { self: async () => null, listPeers: async (params) => listSlackDirectoryPeersFromConfig(params), listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params), + listPeersLive: async (params) => listSlackDirectoryPeersLive(params), + listGroupsLive: async (params) => listSlackDirectoryGroupsLive(params), + }, + resolver: { + resolveTargets: async ({ cfg, accountId, inputs, kind }) => { + const account = resolveSlackAccount({ cfg, accountId }); + const token = account.config.userToken?.trim() || account.botToken?.trim(); + if (!token) { + return inputs.map((input) => ({ + input, + resolved: false, + note: "missing Slack token", + })); + } + if (kind === "group") { + const resolved = await resolveSlackChannelAllowlist({ token, entries: inputs }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.archived ? "archived" : undefined, + })); + } + const resolved = await resolveSlackUserAllowlist({ token, entries: inputs }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); + }, }, actions: { listActions: ({ cfg }) => { diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index 9595cd8f6..dc3dbe962 100644 --- a/src/channels/plugins/telegram.ts +++ b/src/channels/plugins/telegram.ts @@ -141,8 +141,9 @@ export const telegramPlugin: ChannelPlugin = { normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), }; }, - collectWarnings: ({ account }) => { - const groupPolicy = account.config.groupPolicy ?? "allowlist"; + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; const groupAllowlistConfigured = account.config.groups && Object.keys(account.config.groups).length > 0; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index b464c0ed3..832f59e58 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -263,6 +263,26 @@ export type ChannelDirectoryAdapter = { }) => Promise; }; +export type ChannelResolveKind = "user" | "group"; + +export type ChannelResolveResult = { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; +}; + +export type ChannelResolverAdapter = { + resolveTargets: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + inputs: string[]; + kind: ChannelResolveKind; + runtime: RuntimeEnv; + }) => Promise; +}; + export type ChannelElevatedAdapter = { allowFromFallback?: (params: { cfg: ClawdbotConfig; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index e8b912922..66f58e576 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -236,6 +236,7 @@ export type ChannelDirectoryEntry = { name?: string; handle?: string; avatarUrl?: string; + rank?: number; raw?: unknown; }; diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 48c8d46b6..38ed40666 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -4,6 +4,7 @@ import type { ChannelCommandAdapter, ChannelConfigAdapter, ChannelDirectoryAdapter, + ChannelResolverAdapter, ChannelElevatedAdapter, ChannelGatewayAdapter, ChannelGroupAdapter, @@ -68,6 +69,7 @@ export type ChannelPlugin = { threading?: ChannelThreadingAdapter; messaging?: ChannelMessagingAdapter; directory?: ChannelDirectoryAdapter; + resolver?: ChannelResolverAdapter; actions?: ChannelMessageActionAdapter; heartbeat?: ChannelHeartbeatAdapter; // Channel-owned agent tools (login flows, etc.). diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index 696070ba4..ef1c4e20d 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -9,6 +9,9 @@ export type { ChannelCommandAdapter, ChannelConfigAdapter, ChannelDirectoryAdapter, + ChannelResolveKind, + ChannelResolveResult, + ChannelResolverAdapter, ChannelElevatedAdapter, ChannelGatewayAdapter, ChannelGatewayContext, diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts index 91afada7f..34b401776 100644 --- a/src/channels/plugins/whatsapp.ts +++ b/src/channels/plugins/whatsapp.ts @@ -149,8 +149,9 @@ export const whatsappPlugin: ChannelPlugin = { normalizeEntry: (raw) => normalizeE164(raw), }; }, - collectWarnings: ({ account }) => { - const groupPolicy = account.groupPolicy ?? "allowlist"; + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; const groupAllowlistConfigured = Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index e9aeb7277..14f85b52a 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -6,6 +6,7 @@ import { channelsListCommand, channelsLogsCommand, channelsRemoveCommand, + channelsResolveCommand, channelsStatusCommand, } from "../commands/channels.js"; import { danger } from "../globals.js"; @@ -105,6 +106,32 @@ export function registerChannelsCli(program: Command) { } }); + channels + .command("resolve") + .description("Resolve channel/user names to IDs") + .argument("", "Entries to resolve (names or ids)") + .option("--channel ", `Channel (${channelNames})`) + .option("--account ", "Account id (accountId)") + .option("--kind ", "Target kind (auto|user|group)", "auto") + .option("--json", "Output JSON", false) + .action(async (entries, opts) => { + try { + await channelsResolveCommand( + { + channel: opts.channel as string | undefined, + account: opts.account as string | undefined, + kind: opts.kind as "auto" | "user" | "group", + json: Boolean(opts.json), + entries: Array.isArray(entries) ? entries : [String(entries)], + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + channels .command("logs") .description("Show recent channel logs from the gateway log file") diff --git a/src/commands/channels.ts b/src/commands/channels.ts index 8cddf78d4..237916acd 100644 --- a/src/commands/channels.ts +++ b/src/commands/channels.ts @@ -8,5 +8,7 @@ export type { ChannelsLogsOptions } from "./channels/logs.js"; export { channelsLogsCommand } from "./channels/logs.js"; export type { ChannelsRemoveOptions } from "./channels/remove.js"; export { channelsRemoveCommand } from "./channels/remove.js"; +export type { ChannelsResolveOptions } from "./channels/resolve.js"; +export { channelsResolveCommand } from "./channels/resolve.js"; export type { ChannelsStatusOptions } from "./channels/status.js"; export { channelsStatusCommand, formatGatewayChannelsStatusLines } from "./channels/status.js"; diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts new file mode 100644 index 000000000..df67a683e --- /dev/null +++ b/src/commands/channels/resolve.ts @@ -0,0 +1,131 @@ +import { loadConfig } from "../../config/config.js"; +import { danger } from "../../globals.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; +import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js"; +import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +export type ChannelsResolveOptions = { + channel?: string; + account?: string; + kind?: "auto" | "user" | "group" | "channel"; + json?: boolean; + entries?: string[]; +}; + +type ResolveResult = { + input: string; + resolved: boolean; + id?: string; + name?: string; + error?: string; + note?: string; +}; + +function resolvePreferredKind(kind?: ChannelsResolveOptions["kind"]): ChannelResolveKind | undefined { + if (!kind || kind === "auto") return undefined; + if (kind === "user") return "user"; + return "group"; +} + +function detectAutoKind(input: string): ChannelResolveKind { + const trimmed = input.trim(); + if (!trimmed) return "group"; + if (trimmed.startsWith("@")) return "user"; + if (/^<@!?/.test(trimmed)) return "user"; + if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) { + return "user"; + } + return "group"; +} + +function formatResolveResult(result: ResolveResult): string { + if (!result.resolved || !result.id) return `${result.input} -> unresolved`; + const name = result.name ? ` (${result.name})` : ""; + const note = result.note ? ` [${result.note}]` : ""; + return `${result.input} -> ${result.id}${name}${note}`; +} + +export async function channelsResolveCommand( + opts: ChannelsResolveOptions, + runtime: RuntimeEnv, +) { + const cfg = loadConfig(); + const entries = (opts.entries ?? []).map((entry) => entry.trim()).filter(Boolean); + if (entries.length === 0) { + throw new Error("At least one entry is required."); + } + + const selection = await resolveMessageChannelSelection({ + cfg, + channel: opts.channel ?? null, + }); + const plugin = getChannelPlugin(selection.channel); + if (!plugin?.resolver?.resolveTargets) { + throw new Error(`Channel ${selection.channel} does not support resolve.`); + } + const preferredKind = resolvePreferredKind(opts.kind); + + let results: ResolveResult[] = []; + if (preferredKind) { + const resolved = await plugin.resolver.resolveTargets({ + cfg, + accountId: opts.account ?? null, + inputs: entries, + kind: preferredKind, + runtime, + }); + results = resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); + } else { + const byKind = new Map(); + for (const entry of entries) { + const kind = detectAutoKind(entry); + byKind.set(kind, [...(byKind.get(kind) ?? []), entry]); + } + const resolved: ChannelResolveResult[] = []; + for (const [kind, inputs] of byKind.entries()) { + const batch = await plugin.resolver.resolveTargets({ + cfg, + accountId: opts.account ?? null, + inputs, + kind, + runtime, + }); + resolved.push(...batch); + } + const byInput = new Map(resolved.map((entry) => [entry.input, entry])); + results = entries.map((input) => { + const entry = byInput.get(input); + return { + input, + resolved: entry?.resolved ?? false, + id: entry?.id, + name: entry?.name, + note: entry?.note, + }; + }); + } + + if (opts.json) { + runtime.log(JSON.stringify(results, null, 2)); + return; + } + + for (const result of results) { + if (result.resolved && result.id) { + runtime.log(formatResolveResult(result)); + } else { + runtime.error( + danger( + `${result.input} -> unresolved${result.error ? ` (${result.error})` : result.note ? ` (${result.note})` : ""}`, + ), + ); + } + } +} diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index c7666a569..ac98e20de 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -5,8 +5,14 @@ import type { SignalConfig } from "./types.signal.js"; import type { SlackConfig } from "./types.slack.js"; import type { TelegramConfig } from "./types.telegram.js"; import type { WhatsAppConfig } from "./types.whatsapp.js"; +import type { GroupPolicy } from "./types.base.js"; + +export type ChannelDefaultsConfig = { + groupPolicy?: GroupPolicy; +}; export type ChannelsConfig = { + defaults?: ChannelDefaultsConfig; whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index f40f2b917..878cc8787 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -9,12 +9,18 @@ import { TelegramConfigSchema, } from "./zod-schema.providers-core.js"; import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js"; +import { GroupPolicySchema } from "./zod-schema.core.js"; export * from "./zod-schema.providers-core.js"; export * from "./zod-schema.providers-whatsapp.js"; export const ChannelsSchema = z .object({ + defaults: z + .object({ + groupPolicy: GroupPolicySchema.optional(), + }) + .optional(), whatsapp: WhatsAppConfigSchema.optional(), telegram: TelegramConfigSchema.optional(), discord: DiscordConfigSchema.optional(), diff --git a/src/discord/directory-live.ts b/src/discord/directory-live.ts new file mode 100644 index 000000000..45d32f410 --- /dev/null +++ b/src/discord/directory-live.ts @@ -0,0 +1,104 @@ +import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; +import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; +import { resolveDiscordAccount } from "./accounts.js"; +import { normalizeDiscordSlug } from "./monitor/allow-list.js"; +import { normalizeDiscordToken } from "./token.js"; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +type DiscordGuild = { id: string; name: string }; +type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean }; +type DiscordMember = { user: DiscordUser; nick?: string | null }; +type DiscordChannel = { id: string; name?: string | null }; + +async function fetchDiscord(path: string, token: string): Promise { + const res = await fetch(`${DISCORD_API_BASE}${path}`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim().toLowerCase() ?? ""; +} + +function buildUserRank(user: DiscordUser): number { + return user.bot ? 0 : 1; +} + +export async function listDiscordDirectoryGroupsLive( + params: DirectoryConfigParams, +): Promise { + const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); + const token = normalizeDiscordToken(account.token); + if (!token) return []; + const query = normalizeQuery(params.query); + const guilds = await fetchDiscord("/users/@me/guilds", token); + const rows: ChannelDirectoryEntry[] = []; + + for (const guild of guilds) { + const channels = await fetchDiscord(`/guilds/${guild.id}/channels`, token); + for (const channel of channels) { + const name = channel.name?.trim(); + if (!name) continue; + if (query && !normalizeDiscordSlug(name).includes(normalizeDiscordSlug(query))) continue; + rows.push({ + kind: "group", + id: `channel:${channel.id}`, + name, + handle: `#${name}`, + raw: channel, + }); + if (typeof params.limit === "number" && params.limit > 0 && rows.length >= params.limit) { + return rows; + } + } + } + + return rows; +} + +export async function listDiscordDirectoryPeersLive( + params: DirectoryConfigParams, +): Promise { + const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); + const token = normalizeDiscordToken(account.token); + if (!token) return []; + const query = normalizeQuery(params.query); + if (!query) return []; + + const guilds = await fetchDiscord("/users/@me/guilds", token); + const rows: ChannelDirectoryEntry[] = []; + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 25; + + for (const guild of guilds) { + const paramsObj = new URLSearchParams({ + query, + limit: String(Math.min(limit, 100)), + }); + const members = await fetchDiscord( + `/guilds/${guild.id}/members/search?${paramsObj.toString()}`, + token, + ); + for (const member of members) { + const user = member.user; + if (!user?.id) continue; + const name = member.nick?.trim() || user.global_name?.trim() || user.username?.trim(); + rows.push({ + kind: "user", + id: `user:${user.id}`, + name: name || undefined, + handle: user.username ? `@${user.username}` : undefined, + rank: buildUserRank(user), + raw: member, + }); + if (rows.length >= limit) return rows; + } + } + + return rows; +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 7d25e0435..4e4179fa9 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -12,13 +12,15 @@ import { } from "../../config/commands.js"; import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; +import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { createSubsystemLogger } from "../../logging.js"; import type { RuntimeEnv } from "../../runtime.js"; import { resolveDiscordAccount } from "../accounts.js"; import { attachDiscordGatewayLogging } from "../gateway-logging.js"; import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js"; import { fetchDiscordApplicationId } from "../probe.js"; +import { resolveDiscordChannelAllowlist } from "../resolve-channels.js"; +import { resolveDiscordUserAllowlist } from "../resolve-users.js"; import { normalizeDiscordToken } from "../token.js"; import { DiscordMessageListener, @@ -58,6 +60,52 @@ function summarizeGuilds(entries?: Record) { return `${sample.join(", ")}${suffix}`; } +function mergeAllowlist(params: { + existing?: Array; + additions: string[]; +}): string[] { + const seen = new Set(); + const merged: string[] = []; + const push = (value: string) => { + const normalized = value.trim(); + if (!normalized) return; + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + merged.push(normalized); + }; + for (const entry of params.existing ?? []) { + push(String(entry)); + } + for (const entry of params.additions) { + push(entry); + } + return merged; +} + +function summarizeMapping( + label: string, + mapping: string[], + unresolved: string[], + runtime: RuntimeEnv, +) { + const lines: string[] = []; + if (mapping.length > 0) { + const sample = mapping.slice(0, 6); + const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : ""; + lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`); + } + if (unresolved.length > 0) { + const sample = unresolved.slice(0, 6); + const suffix = + unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : ""; + lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`); + } + if (lines.length > 0) { + runtime.log?.(lines.join("\n")); + } +} + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveDiscordAccount({ @@ -81,9 +129,22 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const discordCfg = account.config; const dmConfig = discordCfg.dm; - const guildEntries = discordCfg.guilds; - const groupPolicy = discordCfg.groupPolicy ?? "open"; - const allowFrom = dmConfig?.allowFrom; + let guildEntries = discordCfg.guilds; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = discordCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; + if ( + discordCfg.groupPolicy === undefined && + discordCfg.guilds === undefined && + defaultGroupPolicy === undefined && + groupPolicy === "open" + ) { + runtime.log?.( + warn( + 'discord: groupPolicy defaults to "open" when channels.discord is missing; set channels.discord.groupPolicy (or channels.defaults.groupPolicy) or add channels.discord.guilds to restrict access.', + ), + ); + } + let allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, { fallbackLimit: 2000, @@ -115,6 +176,186 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const sessionPrefix = "discord:slash"; const ephemeralDefault = true; + if (token) { + if (guildEntries && Object.keys(guildEntries).length > 0) { + try { + const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = []; + for (const [guildKey, guildCfg] of Object.entries(guildEntries)) { + if (guildKey === "*") continue; + const channels = guildCfg?.channels ?? {}; + const channelKeys = Object.keys(channels).filter((key) => key !== "*"); + if (channelKeys.length === 0) { + entries.push({ input: guildKey, guildKey }); + continue; + } + for (const channelKey of channelKeys) { + entries.push({ + input: `${guildKey}/${channelKey}`, + guildKey, + channelKey, + }); + } + } + if (entries.length > 0) { + const resolved = await resolveDiscordChannelAllowlist({ + token, + entries: entries.map((entry) => entry.input), + }); + const nextGuilds = { ...(guildEntries ?? {}) }; + const mapping: string[] = []; + const unresolved: string[] = []; + for (const entry of resolved) { + const source = entries.find((item) => item.input === entry.input); + if (!source) continue; + const sourceGuild = guildEntries?.[source.guildKey] ?? {}; + if (!entry.resolved || !entry.guildId) { + unresolved.push(entry.input); + continue; + } + mapping.push( + entry.channelId + ? `${entry.input}→${entry.guildId}/${entry.channelId}` + : `${entry.input}→${entry.guildId}`, + ); + const existing = nextGuilds[entry.guildId] ?? {}; + const mergedChannels = { + ...(sourceGuild.channels ?? {}), + ...(existing.channels ?? {}), + }; + const mergedGuild = { ...sourceGuild, ...existing, channels: mergedChannels }; + nextGuilds[entry.guildId] = mergedGuild; + if (source.channelKey && entry.channelId) { + const sourceChannel = sourceGuild.channels?.[source.channelKey]; + if (sourceChannel) { + nextGuilds[entry.guildId] = { + ...mergedGuild, + channels: { + ...mergedChannels, + [entry.channelId]: { + ...sourceChannel, + ...(mergedChannels?.[entry.channelId] ?? {}), + }, + }, + }; + } + } + } + guildEntries = nextGuilds; + summarizeMapping("discord channels", mapping, unresolved, runtime); + } + } catch (err) { + runtime.log?.(`discord channel resolve failed; using config entries. ${String(err)}`); + } + } + + const allowEntries = + allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? []; + if (allowEntries.length > 0) { + try { + const resolvedUsers = await resolveDiscordUserAllowlist({ + token, + entries: allowEntries.map((entry) => String(entry)), + }); + const mapping: string[] = []; + const unresolved: string[] = []; + const additions: string[] = []; + for (const entry of resolvedUsers) { + if (entry.resolved && entry.id) { + mapping.push(`${entry.input}→${entry.id}`); + additions.push(entry.id); + } else { + unresolved.push(entry.input); + } + } + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + summarizeMapping("discord users", mapping, unresolved, runtime); + } catch (err) { + runtime.log?.(`discord user resolve failed; using config entries. ${String(err)}`); + } + } + + if (guildEntries && Object.keys(guildEntries).length > 0) { + const userEntries = new Set(); + for (const guild of Object.values(guildEntries)) { + if (!guild || typeof guild !== "object") continue; + const users = (guild as { users?: Array }).users; + if (Array.isArray(users)) { + for (const entry of users) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") userEntries.add(trimmed); + } + } + const channels = (guild as { channels?: Record }).channels ?? {}; + for (const channel of Object.values(channels)) { + if (!channel || typeof channel !== "object") continue; + const channelUsers = (channel as { users?: Array }).users; + if (!Array.isArray(channelUsers)) continue; + for (const entry of channelUsers) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") userEntries.add(trimmed); + } + } + } + + if (userEntries.size > 0) { + try { + const resolvedUsers = await resolveDiscordUserAllowlist({ + token, + entries: Array.from(userEntries), + }); + const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry])); + const mapping = resolvedUsers + .filter((entry) => entry.resolved && entry.id) + .map((entry) => `${entry.input}→${entry.id}`); + const unresolved = resolvedUsers + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + + const nextGuilds = { ...(guildEntries ?? {}) }; + for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) { + if (!guildConfig || typeof guildConfig !== "object") continue; + const nextGuild = { ...guildConfig } as Record; + const users = (guildConfig as { users?: Array }).users; + if (Array.isArray(users) && users.length > 0) { + const additions: string[] = []; + for (const entry of users) { + const trimmed = String(entry).trim(); + const resolved = resolvedMap.get(trimmed); + if (resolved?.resolved && resolved.id) additions.push(resolved.id); + } + nextGuild.users = mergeAllowlist({ existing: users, additions }); + } + const channels = (guildConfig as { channels?: Record }).channels ?? {}; + if (channels && typeof channels === "object") { + const nextChannels: Record = { ...channels }; + for (const [channelKey, channelConfig] of Object.entries(channels)) { + if (!channelConfig || typeof channelConfig !== "object") continue; + const channelUsers = (channelConfig as { users?: Array }).users; + if (!Array.isArray(channelUsers) || channelUsers.length === 0) continue; + const additions: string[] = []; + for (const entry of channelUsers) { + const trimmed = String(entry).trim(); + const resolved = resolvedMap.get(trimmed); + if (resolved?.resolved && resolved.id) additions.push(resolved.id); + } + nextChannels[channelKey] = { + ...channelConfig, + users: mergeAllowlist({ existing: channelUsers, additions }), + }; + } + nextGuild.channels = nextChannels; + } + nextGuilds[guildKey] = nextGuild; + } + guildEntries = nextGuilds; + summarizeMapping("discord channel users", mapping, unresolved, runtime); + } catch (err) { + runtime.log?.(`discord channel user resolve failed; using config entries. ${String(err)}`); + } + } + } + } + if (shouldLogVerbose()) { logVerbose( `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`, diff --git a/src/discord/resolve-channels.test.ts b/src/discord/resolve-channels.test.ts new file mode 100644 index 000000000..885f898fe --- /dev/null +++ b/src/discord/resolve-channels.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200 }); +} + +describe("resolveDiscordChannelAllowlist", () => { + it("resolves guild/channel by name", async () => { + const fetcher = async (url: string) => { + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([{ id: "g1", name: "My Guild" }]); + } + if (url.endsWith("/guilds/g1/channels")) { + return jsonResponse([ + { id: "c1", name: "general", guild_id: "g1", type: 0 }, + { id: "c2", name: "random", guild_id: "g1", type: 0 }, + ]); + } + return new Response("not found", { status: 404 }); + }; + + const res = await resolveDiscordChannelAllowlist({ + token: "test", + entries: ["My Guild/general"], + fetcher, + }); + + expect(res[0]?.resolved).toBe(true); + expect(res[0]?.guildId).toBe("g1"); + expect(res[0]?.channelId).toBe("c1"); + }); + + it("resolves channel id to guild", async () => { + const fetcher = async (url: string) => { + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([{ id: "g1", name: "Guild One" }]); + } + if (url.endsWith("/channels/123")) { + return jsonResponse({ id: "123", name: "general", guild_id: "g1", type: 0 }); + } + return new Response("not found", { status: 404 }); + }; + + const res = await resolveDiscordChannelAllowlist({ + token: "test", + entries: ["123"], + fetcher, + }); + + expect(res[0]?.resolved).toBe(true); + expect(res[0]?.guildId).toBe("g1"); + expect(res[0]?.channelId).toBe("123"); + }); +}); diff --git a/src/discord/resolve-channels.ts b/src/discord/resolve-channels.ts new file mode 100644 index 000000000..05c185701 --- /dev/null +++ b/src/discord/resolve-channels.ts @@ -0,0 +1,317 @@ +import type { RESTGetAPIChannelResult, RESTGetAPIGuildChannelsResult } from "discord-api-types/v10"; + +import { normalizeDiscordSlug } from "./monitor/allow-list.js"; +import { normalizeDiscordToken } from "./token.js"; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +type DiscordGuildSummary = { + id: string; + name: string; + slug: string; +}; + +type DiscordChannelSummary = { + id: string; + name: string; + guildId: string; + type?: number; + archived?: boolean; +}; + +export type DiscordChannelResolution = { + input: string; + resolved: boolean; + guildId?: string; + guildName?: string; + channelId?: string; + channelName?: string; + archived?: boolean; + note?: string; +}; + +function parseDiscordChannelInput(raw: string): { + guild?: string; + channel?: string; + channelId?: string; + guildId?: string; + guildOnly?: boolean; +} { + const trimmed = raw.trim(); + if (!trimmed) return {}; + const mention = trimmed.match(/^<#(\d+)>$/); + if (mention) return { channelId: mention[1] }; + const channelPrefix = trimmed.match(/^(?:channel:|discord:)?(\d+)$/i); + if (channelPrefix) return { channelId: channelPrefix[1] }; + const guildPrefix = trimmed.match(/^(?:guild:|server:)?(\d+)$/i); + if (guildPrefix && !trimmed.includes("/") && !trimmed.includes("#")) { + return { guildId: guildPrefix[1], guildOnly: true }; + } + const split = trimmed.includes("/") ? trimmed.split("/") : trimmed.split("#"); + if (split.length >= 2) { + const guild = split[0]?.trim(); + const channel = split.slice(1).join("#").trim(); + if (!channel) { + return guild ? { guild: guild.trim(), guildOnly: true } : {}; + } + if (guild && /^\d+$/.test(guild)) return { guildId: guild, channel }; + return { guild, channel }; + } + return { guild: trimmed, guildOnly: true }; +} + +async function fetchDiscord( + path: string, + token: string, + fetcher: typeof fetch, +): Promise { + const res = await fetcher(`${DISCORD_API_BASE}${path}`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +async function listGuilds(token: string, fetcher: typeof fetch): Promise { + const raw = await fetchDiscord>( + "/users/@me/guilds", + token, + fetcher, + ); + return raw.map((guild) => ({ + id: guild.id, + name: guild.name, + slug: normalizeDiscordSlug(guild.name), + })); +} + +async function listGuildChannels( + token: string, + fetcher: typeof fetch, + guildId: string, +): Promise { + const raw = (await fetchDiscord( + `/guilds/${guildId}/channels`, + token, + fetcher, + )) as RESTGetAPIGuildChannelsResult; + return raw + .filter((channel) => Boolean(channel.id) && "name" in channel) + .map((channel) => ({ + id: channel.id, + name: "name" in channel ? channel.name ?? "" : "", + guildId, + type: channel.type, + archived: "thread_metadata" in channel ? channel.thread_metadata?.archived : undefined, + })) + .filter((channel) => Boolean(channel.name)); +} + +async function fetchChannel( + token: string, + fetcher: typeof fetch, + channelId: string, +): Promise { + const raw = (await fetchDiscord( + `/channels/${channelId}`, + token, + fetcher, + )) as RESTGetAPIChannelResult; + if (!raw || !("guild_id" in raw)) return null; + return { + id: raw.id, + name: "name" in raw ? raw.name ?? "" : "", + guildId: raw.guild_id ?? "", + type: raw.type, + }; +} + +function preferActiveMatch(candidates: DiscordChannelSummary[]): DiscordChannelSummary | undefined { + if (candidates.length === 0) return undefined; + const scored = candidates.map((channel) => { + const isThread = channel.type === 11 || channel.type === 12; + const archived = Boolean(channel.archived); + const score = (archived ? 0 : 2) + (isThread ? 0 : 1); + return { channel, score }; + }); + scored.sort((a, b) => b.score - a.score); + return scored[0]?.channel ?? candidates[0]; +} + +function resolveGuildByName( + guilds: DiscordGuildSummary[], + input: string, +): DiscordGuildSummary | undefined { + const slug = normalizeDiscordSlug(input); + if (!slug) return undefined; + return guilds.find((guild) => guild.slug === slug); +} + +export async function resolveDiscordChannelAllowlist(params: { + token: string; + entries: string[]; + fetcher?: typeof fetch; +}): Promise { + const token = normalizeDiscordToken(params.token); + if (!token) + return params.entries.map((input) => ({ + input, + resolved: false, + })); + const fetcher = params.fetcher ?? fetch; + const guilds = await listGuilds(token, fetcher); + const channelsByGuild = new Map>(); + const getChannels = (guildId: string) => { + const existing = channelsByGuild.get(guildId); + if (existing) return existing; + const promise = listGuildChannels(token, fetcher, guildId); + channelsByGuild.set(guildId, promise); + return promise; + }; + + const results: DiscordChannelResolution[] = []; + + for (const input of params.entries) { + const parsed = parseDiscordChannelInput(input); + if (parsed.guildOnly) { + const guild = + parsed.guildId && guilds.find((entry) => entry.id === parsed.guildId) + ? guilds.find((entry) => entry.id === parsed.guildId) + : parsed.guild + ? resolveGuildByName(guilds, parsed.guild) + : undefined; + if (guild) { + results.push({ + input, + resolved: true, + guildId: guild.id, + guildName: guild.name, + }); + } else { + results.push({ + input, + resolved: false, + guildId: parsed.guildId, + guildName: parsed.guild, + }); + } + continue; + } + + if (parsed.channelId) { + const channel = await fetchChannel(token, fetcher, parsed.channelId); + if (channel?.guildId) { + const guild = guilds.find((entry) => entry.id === channel.guildId); + results.push({ + input, + resolved: true, + guildId: channel.guildId, + guildName: guild?.name, + channelId: channel.id, + channelName: channel.name, + archived: channel.archived, + }); + } else { + results.push({ + input, + resolved: false, + channelId: parsed.channelId, + }); + } + continue; + } + + if (parsed.guildId || parsed.guild) { + const guild = + parsed.guildId && guilds.find((entry) => entry.id === parsed.guildId) + ? guilds.find((entry) => entry.id === parsed.guildId) + : parsed.guild + ? resolveGuildByName(guilds, parsed.guild) + : undefined; + if (!guild || !parsed.channel) { + results.push({ + input, + resolved: false, + guildId: parsed.guildId, + guildName: parsed.guild, + channelName: parsed.channel, + }); + continue; + } + const channels = await getChannels(guild.id); + const matches = channels.filter( + (channel) => normalizeDiscordSlug(channel.name) === normalizeDiscordSlug(parsed.channel), + ); + const match = preferActiveMatch(matches); + if (match) { + results.push({ + input, + resolved: true, + guildId: guild.id, + guildName: guild.name, + channelId: match.id, + channelName: match.name, + archived: match.archived, + }); + } else { + results.push({ + input, + resolved: false, + guildId: guild.id, + guildName: guild.name, + channelName: parsed.channel, + note: `channel not found in guild ${guild.name}`, + }); + } + continue; + } + + const channelName = input.trim().replace(/^#/, ""); + if (!channelName) { + results.push({ + input, + resolved: false, + channelName: channelName, + }); + continue; + } + const candidates: DiscordChannelSummary[] = []; + for (const guild of guilds) { + const channels = await getChannels(guild.id); + for (const channel of channels) { + if (normalizeDiscordSlug(channel.name) === normalizeDiscordSlug(channelName)) { + candidates.push(channel); + } + } + } + const match = preferActiveMatch(candidates); + if (match) { + const guild = guilds.find((entry) => entry.id === match.guildId); + results.push({ + input, + resolved: true, + guildId: match.guildId, + guildName: guild?.name, + channelId: match.id, + channelName: match.name, + archived: match.archived, + note: + candidates.length > 1 && guild?.name + ? `matched multiple; chose ${guild.name}` + : undefined, + }); + continue; + } + + results.push({ + input, + resolved: false, + channelName: channelName, + }); + } + + return results; +} diff --git a/src/discord/resolve-users.ts b/src/discord/resolve-users.ts new file mode 100644 index 000000000..a43bf8b56 --- /dev/null +++ b/src/discord/resolve-users.ts @@ -0,0 +1,178 @@ +import { normalizeDiscordSlug } from "./monitor/allow-list.js"; +import { normalizeDiscordToken } from "./token.js"; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +type DiscordGuildSummary = { + id: string; + name: string; + slug: string; +}; + +type DiscordUser = { + id: string; + username: string; + discriminator?: string; + global_name?: string; + bot?: boolean; +}; + +type DiscordMember = { + user: DiscordUser; + nick?: string | null; +}; + +export type DiscordUserResolution = { + input: string; + resolved: boolean; + id?: string; + name?: string; + guildId?: string; + guildName?: string; + note?: string; +}; + +function parseDiscordUserInput(raw: string): { + userId?: string; + guildId?: string; + guildName?: string; + userName?: string; +} { + const trimmed = raw.trim(); + if (!trimmed) return {}; + const mention = trimmed.match(/^<@!?(\d+)>$/); + if (mention) return { userId: mention[1] }; + const prefixed = trimmed.match(/^(?:user:|discord:)?(\d+)$/i); + if (prefixed) return { userId: prefixed[1] }; + const split = trimmed.includes("/") ? trimmed.split("/") : trimmed.split("#"); + if (split.length >= 2) { + const guild = split[0]?.trim(); + const user = split.slice(1).join("#").trim(); + if (guild && /^\d+$/.test(guild)) return { guildId: guild, userName: user }; + return { guildName: guild, userName: user }; + } + return { userName: trimmed.replace(/^@/, "") }; +} + +async function fetchDiscord(path: string, token: string, fetcher: typeof fetch): Promise { + const res = await fetcher(`${DISCORD_API_BASE}${path}`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +async function listGuilds(token: string, fetcher: typeof fetch): Promise { + const raw = await fetchDiscord>( + "/users/@me/guilds", + token, + fetcher, + ); + return raw.map((guild) => ({ + id: guild.id, + name: guild.name, + slug: normalizeDiscordSlug(guild.name), + })); +} + +function scoreDiscordMember(member: DiscordMember, query: string): number { + const q = query.toLowerCase(); + const user = member.user; + const candidates = [ + user.username, + user.global_name, + member.nick ?? undefined, + ] + .map((value) => value?.toLowerCase()) + .filter(Boolean) as string[]; + let score = 0; + if (candidates.some((value) => value === q)) score += 3; + if (candidates.some((value) => value?.includes(q))) score += 1; + if (!user.bot) score += 1; + return score; +} + +export async function resolveDiscordUserAllowlist(params: { + token: string; + entries: string[]; + fetcher?: typeof fetch; +}): Promise { + const token = normalizeDiscordToken(params.token); + if (!token) + return params.entries.map((input) => ({ + input, + resolved: false, + })); + const fetcher = params.fetcher ?? fetch; + const guilds = await listGuilds(token, fetcher); + const results: DiscordUserResolution[] = []; + + for (const input of params.entries) { + const parsed = parseDiscordUserInput(input); + if (parsed.userId) { + results.push({ + input, + resolved: true, + id: parsed.userId, + }); + continue; + } + + const query = parsed.userName?.trim(); + if (!query) { + results.push({ input, resolved: false }); + continue; + } + + const guildList = parsed.guildId + ? guilds.filter((g) => g.id === parsed.guildId) + : parsed.guildName + ? guilds.filter((g) => g.slug === normalizeDiscordSlug(parsed.guildName)) + : guilds; + + let best: { member: DiscordMember; guild: DiscordGuildSummary; score: number } | null = null; + let matches = 0; + + for (const guild of guildList) { + const paramsObj = new URLSearchParams({ + query, + limit: "25", + }); + const members = await fetchDiscord( + `/guilds/${guild.id}/members/search?${paramsObj.toString()}`, + token, + fetcher, + ); + for (const member of members) { + const score = scoreDiscordMember(member, query); + if (score === 0) continue; + matches += 1; + if (!best || score > best.score) { + best = { member, guild, score }; + } + } + } + + if (best) { + const user = best.member.user; + const name = + best.member.nick?.trim() || user.global_name?.trim() || user.username?.trim() || undefined; + results.push({ + input, + resolved: true, + id: user.id, + name, + guildId: best.guild.id, + guildName: best.guild.name, + note: matches > 1 ? "multiple matches; chose best" : undefined, + }); + } else { + results.push({ input, resolved: false }); + } + } + + return results; +} diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 7add40386..4ee271eac 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -105,7 +105,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P imessageCfg.groupAllowFrom ?? (imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []), ); - const groupPolicy = imessageCfg.groupPolicy ?? "open"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = imessageCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024; diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 6b6f3f8f9..d21685a93 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -16,6 +16,8 @@ import { ambiguousTargetError, unknownTargetError } from "./target-errors.js"; export type TargetResolveKind = ChannelDirectoryEntryKind | "channel"; +export type ResolveAmbiguousMode = "error" | "best" | "first"; + export type ResolvedMessagingTarget = { to: string; kind: TargetResolveKind; @@ -249,6 +251,21 @@ async function getDirectoryEntries(params: { return liveEntries; } +function pickAmbiguousMatch( + entries: ChannelDirectoryEntry[], + mode: ResolveAmbiguousMode, +): ChannelDirectoryEntry | null { + if (entries.length === 0) return null; + if (mode === "first") return entries[0] ?? null; + const ranked = entries.map((entry) => ({ + entry, + rank: typeof entry.rank === "number" ? entry.rank : 0, + })); + const bestRank = Math.max(...ranked.map((item) => item.rank)); + const best = ranked.find((item) => item.rank === bestRank)?.entry; + return best ?? entries[0] ?? null; +} + export async function resolveMessagingTarget(params: { cfg: ClawdbotConfig; channel: ChannelId; @@ -256,6 +273,7 @@ export async function resolveMessagingTarget(params: { accountId?: string | null; preferredKind?: TargetResolveKind; runtime?: RuntimeEnv; + resolveAmbiguous?: ResolveAmbiguousMode; }): Promise { const raw = normalizeChannelTargetInput(params.input); if (!raw) { @@ -314,6 +332,21 @@ export async function resolveMessagingTarget(params: { }; } if (match.kind === "ambiguous") { + const mode = params.resolveAmbiguous ?? "error"; + if (mode !== "error") { + const best = pickAmbiguousMatch(match.entries, mode); + if (best) { + return { + ok: true, + target: { + to: normalizeDirectoryEntryId(params.channel, best), + kind, + display: best.name ?? best.handle ?? stripTargetPrefixes(best.id), + source: "directory", + }, + }; + } + } return { ok: false, error: ambiguousTargetError(providerLabel, raw, hint), diff --git a/src/security/audit.ts b/src/security/audit.ts index 0ae2f455b..b1db1610d 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -492,7 +492,9 @@ async function collectChannelSecurityFindings(params: { }); const slashEnabled = nativeEnabled || nativeSkillsEnabled; if (slashEnabled) { - const groupPolicy = (discordCfg.groupPolicy as string | undefined) ?? "allowlist"; + const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy; + const groupPolicy = + (discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist"; const guildEntries = (discordCfg.guilds as Record | undefined) ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => { @@ -652,7 +654,9 @@ async function collectChannelSecurityFindings(params: { const telegramCfg = (account as { config?: Record } | null)?.config ?? ({} as Record); - const groupPolicy = (telegramCfg.groupPolicy as string | undefined) ?? "allowlist"; + const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy; + const groupPolicy = + (telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist"; const groups = telegramCfg.groups as Record | undefined; const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0; const groupAccessPossible = diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 98b950e9b..b58a59e0f 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -273,7 +273,8 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi ? accountInfo.config.allowFrom : []), ); - const groupPolicy = accountInfo.config.groupPolicy ?? "allowlist"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = accountInfo.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const reactionMode = accountInfo.config.reactionNotifications ?? "own"; const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist); const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; diff --git a/src/slack/directory-live.ts b/src/slack/directory-live.ts new file mode 100644 index 000000000..3b9cab871 --- /dev/null +++ b/src/slack/directory-live.ts @@ -0,0 +1,163 @@ +import { WebClient } from "@slack/web-api"; + +import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; +import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; +import { resolveSlackAccount } from "./accounts.js"; + +type SlackUser = { + id?: string; + name?: string; + real_name?: string; + is_bot?: boolean; + is_app_user?: boolean; + deleted?: boolean; + profile?: { + display_name?: string; + real_name?: string; + email?: string; + }; +}; + +type SlackChannel = { + id?: string; + name?: string; + is_archived?: boolean; + is_private?: boolean; +}; + +type SlackListUsersResponse = { + members?: SlackUser[]; + response_metadata?: { next_cursor?: string }; +}; + +type SlackListChannelsResponse = { + channels?: SlackChannel[]; + response_metadata?: { next_cursor?: string }; +}; + +function resolveReadToken(params: DirectoryConfigParams): string | undefined { + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const userToken = account.config.userToken?.trim() || undefined; + return userToken ?? account.botToken?.trim(); +} + +function normalizeQuery(value?: string | null): string { + return value?.trim().toLowerCase() ?? ""; +} + +function buildUserRank(user: SlackUser): number { + let rank = 0; + if (!user.deleted) rank += 2; + if (!user.is_bot && !user.is_app_user) rank += 1; + return rank; +} + +function buildChannelRank(channel: SlackChannel): number { + return channel.is_archived ? 0 : 1; +} + +export async function listSlackDirectoryPeersLive( + params: DirectoryConfigParams, +): Promise { + const token = resolveReadToken(params); + if (!token) return []; + const client = new WebClient(token); + const query = normalizeQuery(params.query); + const members: SlackUser[] = []; + let cursor: string | undefined; + + do { + const res = (await client.users.list({ + limit: 200, + cursor, + })) as SlackListUsersResponse; + if (Array.isArray(res.members)) members.push(...res.members); + const next = res.response_metadata?.next_cursor?.trim(); + cursor = next ? next : undefined; + } while (cursor); + + const filtered = members.filter((member) => { + const name = member.profile?.display_name || member.profile?.real_name || member.real_name; + const handle = member.name; + const email = member.profile?.email; + const candidates = [name, handle, email].map((item) => item?.trim().toLowerCase()).filter(Boolean); + if (!query) return true; + return candidates.some((candidate) => candidate?.includes(query)); + }); + + const rows = filtered + .map((member) => { + const id = member.id?.trim(); + if (!id) return null; + const handle = member.name?.trim(); + const display = + member.profile?.display_name?.trim() || + member.profile?.real_name?.trim() || + member.real_name?.trim() || + handle; + return { + kind: "user", + id: `user:${id}`, + name: display || undefined, + handle: handle ? `@${handle}` : undefined, + rank: buildUserRank(member), + raw: member, + } satisfies ChannelDirectoryEntry; + }) + .filter(Boolean) as ChannelDirectoryEntry[]; + + if (typeof params.limit === "number" && params.limit > 0) { + return rows.slice(0, params.limit); + } + return rows; +} + +export async function listSlackDirectoryGroupsLive( + params: DirectoryConfigParams, +): Promise { + const token = resolveReadToken(params); + if (!token) return []; + const client = new WebClient(token); + const query = normalizeQuery(params.query); + const channels: SlackChannel[] = []; + let cursor: string | undefined; + + do { + const res = (await client.conversations.list({ + types: "public_channel,private_channel", + exclude_archived: false, + limit: 1000, + cursor, + })) as SlackListChannelsResponse; + if (Array.isArray(res.channels)) channels.push(...res.channels); + const next = res.response_metadata?.next_cursor?.trim(); + cursor = next ? next : undefined; + } while (cursor); + + const filtered = channels.filter((channel) => { + const name = channel.name?.trim().toLowerCase(); + if (!query) return true; + return Boolean(name && name.includes(query)); + }); + + const rows = filtered + .map((channel) => { + const id = channel.id?.trim(); + const name = channel.name?.trim(); + if (!id || !name) return null; + return { + kind: "group", + id: `channel:${id}`, + name, + handle: `#${name}`, + rank: buildChannelRank(channel), + raw: channel, + } satisfies ChannelDirectoryEntry; + }) + .filter(Boolean) as ChannelDirectoryEntry[]; + + if (typeof params.limit === "number" && params.limit > 0) { + return rows.slice(0, params.limit); + } + return rows; +} diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 0e93d8269..69e805042 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -5,10 +5,13 @@ import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; import { loadConfig } from "../../config/config.js"; import type { SessionScope } from "../../config/sessions.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; +import { warn } from "../../globals.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; import { resolveSlackAccount } from "../accounts.js"; +import { resolveSlackChannelAllowlist } from "../resolve-channels.js"; +import { resolveSlackUserAllowlist } from "../resolve-users.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js"; import { resolveSlackSlashCommandConfig } from "./commands.js"; import { createSlackMonitorContext } from "./context.js"; @@ -25,10 +28,56 @@ function parseApiAppIdFromAppToken(raw?: string) { return match?.[1]?.toUpperCase(); } +function mergeAllowlist(params: { + existing?: Array; + additions: string[]; +}): string[] { + const seen = new Set(); + const merged: string[] = []; + const push = (value: string) => { + const normalized = value.trim(); + if (!normalized) return; + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + merged.push(normalized); + }; + for (const entry of params.existing ?? []) { + push(String(entry)); + } + for (const entry of params.additions) { + push(entry); + } + return merged; +} + +function summarizeMapping( + label: string, + mapping: string[], + unresolved: string[], + runtime: RuntimeEnv, +) { + const lines: string[] = []; + if (mapping.length > 0) { + const sample = mapping.slice(0, 6); + const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : ""; + lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`); + } + if (unresolved.length > 0) { + const sample = unresolved.slice(0, 6); + const suffix = + unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : ""; + lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`); + } + if (lines.length > 0) { + runtime.log?.(lines.join("\n")); + } +} + export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const cfg = opts.config ?? loadConfig(); - const account = resolveSlackAccount({ + let account = resolveSlackAccount({ cfg, accountId: opts.accountId, }); @@ -65,11 +114,128 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const dmEnabled = dmConfig?.enabled ?? true; const dmPolicy = (dmConfig?.policy ?? "pairing") as DmPolicy; - const allowFrom = dmConfig?.allowFrom; + let allowFrom = dmConfig?.allowFrom; const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; - const channelsConfig = slackCfg.channels; - const groupPolicy = (slackCfg.groupPolicy ?? "open") as GroupPolicy; + let channelsConfig = slackCfg.channels; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = (slackCfg.groupPolicy ?? defaultGroupPolicy ?? "open") as GroupPolicy; + if ( + slackCfg.groupPolicy === undefined && + slackCfg.channels === undefined && + defaultGroupPolicy === undefined && + groupPolicy === "open" + ) { + runtime.log?.( + warn( + 'slack: groupPolicy defaults to "open" when channels.slack is missing; set channels.slack.groupPolicy (or channels.defaults.groupPolicy) or add channels.slack.channels to restrict access.', + ), + ); + } + + const resolveToken = slackCfg.userToken?.trim() || botToken; + if (resolveToken) { + if (channelsConfig && Object.keys(channelsConfig).length > 0) { + try { + const entries = Object.keys(channelsConfig); + const resolved = await resolveSlackChannelAllowlist({ + token: resolveToken, + entries, + }); + const resolvedMap: string[] = []; + const unresolved: string[] = []; + const nextChannels = { ...channelsConfig }; + for (const entry of resolved) { + if (entry.resolved && entry.id) { + resolvedMap.push(`${entry.input}→${entry.id}`); + if (!nextChannels[entry.id] && channelsConfig[entry.input]) { + nextChannels[entry.id] = channelsConfig[entry.input]; + } + } else { + unresolved.push(entry.input); + } + } + channelsConfig = nextChannels; + summarizeMapping("slack channels", resolvedMap, unresolved, runtime); + } catch (err) { + runtime.log?.(`slack channel resolve failed; using config entries. ${String(err)}`); + } + } + + const allowEntries = + allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? []; + if (allowEntries.length > 0) { + try { + const resolvedUsers = await resolveSlackUserAllowlist({ + token: resolveToken, + entries: allowEntries.map((entry) => String(entry)), + }); + const resolvedMap: string[] = []; + const unresolved: string[] = []; + const additions: string[] = []; + for (const entry of resolvedUsers) { + if (entry.resolved && entry.id) { + resolvedMap.push(`${entry.input}→${entry.id}`); + additions.push(entry.id); + } else { + unresolved.push(entry.input); + } + } + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + summarizeMapping("slack users", resolvedMap, unresolved, runtime); + } catch (err) { + runtime.log?.(`slack user resolve failed; using config entries. ${String(err)}`); + } + } + + if (channelsConfig && Object.keys(channelsConfig).length > 0) { + const userEntries = new Set(); + for (const channel of Object.values(channelsConfig)) { + if (!channel || typeof channel !== "object") continue; + const users = (channel as { users?: Array }).users; + if (!Array.isArray(users)) continue; + for (const entry of users) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") userEntries.add(trimmed); + } + } + if (userEntries.size > 0) { + try { + const resolvedUsers = await resolveSlackUserAllowlist({ + token: resolveToken, + entries: Array.from(userEntries), + }); + const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry])); + const mapping = resolvedUsers + .filter((entry) => entry.resolved && entry.id) + .map((entry) => `${entry.input}→${entry.id}`); + const unresolved = resolvedUsers + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + const nextChannels = { ...channelsConfig }; + for (const [channelId, channelConfig] of Object.entries(channelsConfig)) { + if (!channelConfig || typeof channelConfig !== "object") continue; + const users = (channelConfig as { users?: Array }).users; + if (!Array.isArray(users) || users.length === 0) continue; + const additions: string[] = []; + for (const entry of users) { + const trimmed = String(entry).trim(); + const resolved = resolvedMap.get(trimmed); + if (resolved?.resolved && resolved.id) additions.push(resolved.id); + } + nextChannels[channelId] = { + ...channelConfig, + users: mergeAllowlist({ existing: users, additions }), + }; + } + channelsConfig = nextChannels; + summarizeMapping("slack channel users", mapping, unresolved, runtime); + } catch (err) { + runtime.log?.(`slack channel user resolve failed; using config entries. ${String(err)}`); + } + } + } + } const useAccessGroups = cfg.commands?.useAccessGroups !== false; const reactionMode = slackCfg.reactionNotifications ?? "own"; const reactionAllowlist = slackCfg.reactionAllowlist ?? []; diff --git a/src/slack/resolve-channels.test.ts b/src/slack/resolve-channels.test.ts new file mode 100644 index 000000000..27ea0f4ed --- /dev/null +++ b/src/slack/resolve-channels.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; + +describe("resolveSlackChannelAllowlist", () => { + it("resolves by name and prefers active channels", async () => { + const client = { + conversations: { + list: vi.fn().mockResolvedValue({ + channels: [ + { id: "C1", name: "general", is_archived: true }, + { id: "C2", name: "general", is_archived: false }, + ], + }), + }, + }; + + const res = await resolveSlackChannelAllowlist({ + token: "xoxb-test", + entries: ["#general"], + client: client as never, + }); + + expect(res[0]?.resolved).toBe(true); + expect(res[0]?.id).toBe("C2"); + }); + + it("keeps unresolved entries", async () => { + const client = { + conversations: { + list: vi.fn().mockResolvedValue({ channels: [] }), + }, + }; + + const res = await resolveSlackChannelAllowlist({ + token: "xoxb-test", + entries: ["#does-not-exist"], + client: client as never, + }); + + expect(res[0]?.resolved).toBe(false); + }); +}); diff --git a/src/slack/resolve-channels.ts b/src/slack/resolve-channels.ts new file mode 100644 index 000000000..2b70e6d98 --- /dev/null +++ b/src/slack/resolve-channels.ts @@ -0,0 +1,121 @@ +import { WebClient } from "@slack/web-api"; + +export type SlackChannelLookup = { + id: string; + name: string; + archived: boolean; + isPrivate: boolean; +}; + +export type SlackChannelResolution = { + input: string; + resolved: boolean; + id?: string; + name?: string; + archived?: boolean; +}; + +type SlackListResponse = { + channels?: Array<{ + id?: string; + name?: string; + is_archived?: boolean; + is_private?: boolean; + }>; + response_metadata?: { next_cursor?: string }; +}; + +function parseSlackChannelMention(raw: string): { id?: string; name?: string } { + const trimmed = raw.trim(); + if (!trimmed) return {}; + const mention = trimmed.match(/^<#([A-Z0-9]+)(?:\|([^>]+))?>$/i); + if (mention) { + const id = mention[1]?.toUpperCase(); + const name = mention[2]?.trim(); + return { id, name }; + } + const prefixed = trimmed.replace(/^(slack:|channel:)/i, ""); + if (/^[CG][A-Z0-9]+$/i.test(prefixed)) return { id: prefixed.toUpperCase() }; + const name = prefixed.replace(/^#/, "").trim(); + return name ? { name } : {}; +} + +async function listSlackChannels(client: WebClient): Promise { + const channels: SlackChannelLookup[] = []; + let cursor: string | undefined; + do { + const res = (await client.conversations.list({ + types: "public_channel,private_channel", + exclude_archived: false, + limit: 1000, + cursor, + })) as SlackListResponse; + for (const channel of res.channels ?? []) { + const id = channel.id?.trim(); + const name = channel.name?.trim(); + if (!id || !name) continue; + channels.push({ + id, + name, + archived: Boolean(channel.is_archived), + isPrivate: Boolean(channel.is_private), + }); + } + const next = res.response_metadata?.next_cursor?.trim(); + cursor = next ? next : undefined; + } while (cursor); + return channels; +} + +function resolveByName( + name: string, + channels: SlackChannelLookup[], +): SlackChannelLookup | undefined { + const target = name.trim().toLowerCase(); + if (!target) return undefined; + const matches = channels.filter((channel) => channel.name.toLowerCase() === target); + if (matches.length === 0) return undefined; + const active = matches.find((channel) => !channel.archived); + return active ?? matches[0]; +} + +export async function resolveSlackChannelAllowlist(params: { + token: string; + entries: string[]; + client?: WebClient; +}): Promise { + const client = params.client ?? new WebClient(params.token); + const channels = await listSlackChannels(client); + const results: SlackChannelResolution[] = []; + + for (const input of params.entries) { + const parsed = parseSlackChannelMention(input); + if (parsed.id) { + const match = channels.find((channel) => channel.id === parsed.id); + results.push({ + input, + resolved: true, + id: parsed.id, + name: match?.name ?? parsed.name, + archived: match?.archived, + }); + continue; + } + if (parsed.name) { + const match = resolveByName(parsed.name, channels); + if (match) { + results.push({ + input, + resolved: true, + id: match.id, + name: match.name, + archived: match.archived, + }); + continue; + } + } + results.push({ input, resolved: false }); + } + + return results; +} diff --git a/src/slack/resolve-users.ts b/src/slack/resolve-users.ts new file mode 100644 index 000000000..65183615e --- /dev/null +++ b/src/slack/resolve-users.ts @@ -0,0 +1,182 @@ +import { WebClient } from "@slack/web-api"; + +export type SlackUserLookup = { + id: string; + name: string; + displayName?: string; + realName?: string; + email?: string; + deleted: boolean; + isBot: boolean; + isAppUser: boolean; +}; + +export type SlackUserResolution = { + input: string; + resolved: boolean; + id?: string; + name?: string; + email?: string; + deleted?: boolean; + isBot?: boolean; + note?: string; +}; + +type SlackListUsersResponse = { + members?: Array<{ + id?: string; + name?: string; + deleted?: boolean; + is_bot?: boolean; + is_app_user?: boolean; + real_name?: string; + profile?: { + display_name?: string; + real_name?: string; + email?: string; + }; + }>; + response_metadata?: { next_cursor?: string }; +}; + +function parseSlackUserInput(raw: string): { id?: string; name?: string; email?: string } { + const trimmed = raw.trim(); + if (!trimmed) return {}; + const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i); + if (mention) return { id: mention[1]?.toUpperCase() }; + const prefixed = trimmed.replace(/^(slack:|user:)/i, ""); + if (/^[A-Z][A-Z0-9]+$/i.test(prefixed)) return { id: prefixed.toUpperCase() }; + if (trimmed.includes("@") && !trimmed.startsWith("@")) return { email: trimmed.toLowerCase() }; + const name = trimmed.replace(/^@/, "").trim(); + return name ? { name } : {}; +} + +async function listSlackUsers(client: WebClient): Promise { + const users: SlackUserLookup[] = []; + let cursor: string | undefined; + do { + const res = (await client.users.list({ + limit: 200, + cursor, + })) as SlackListUsersResponse; + for (const member of res.members ?? []) { + const id = member.id?.trim(); + const name = member.name?.trim(); + if (!id || !name) continue; + const profile = member.profile ?? {}; + users.push({ + id, + name, + displayName: profile.display_name?.trim() || undefined, + realName: profile.real_name?.trim() || member.real_name?.trim() || undefined, + email: profile.email?.trim()?.toLowerCase() || undefined, + deleted: Boolean(member.deleted), + isBot: Boolean(member.is_bot), + isAppUser: Boolean(member.is_app_user), + }); + } + const next = res.response_metadata?.next_cursor?.trim(); + cursor = next ? next : undefined; + } while (cursor); + return users; +} + +function scoreSlackUser(user: SlackUserLookup, match: { name?: string; email?: string }): number { + let score = 0; + if (!user.deleted) score += 3; + if (!user.isBot && !user.isAppUser) score += 2; + if (match.email && user.email === match.email) score += 5; + if (match.name) { + const target = match.name.toLowerCase(); + const candidates = [ + user.name, + user.displayName, + user.realName, + ] + .map((value) => value?.toLowerCase()) + .filter(Boolean) as string[]; + if (candidates.some((value) => value === target)) score += 2; + } + return score; +} + +export async function resolveSlackUserAllowlist(params: { + token: string; + entries: string[]; + client?: WebClient; +}): Promise { + const client = params.client ?? new WebClient(params.token); + const users = await listSlackUsers(client); + const results: SlackUserResolution[] = []; + + for (const input of params.entries) { + const parsed = parseSlackUserInput(input); + if (parsed.id) { + const match = users.find((user) => user.id === parsed.id); + results.push({ + input, + resolved: true, + id: parsed.id, + name: match?.displayName ?? match?.realName ?? match?.name, + email: match?.email, + deleted: match?.deleted, + isBot: match?.isBot, + }); + continue; + } + if (parsed.email) { + const matches = users.filter((user) => user.email === parsed.email); + if (matches.length > 0) { + const scored = matches + .map((user) => ({ user, score: scoreSlackUser(user, parsed) })) + .sort((a, b) => b.score - a.score); + const best = scored[0]?.user ?? matches[0]; + results.push({ + input, + resolved: true, + id: best.id, + name: best.displayName ?? best.realName ?? best.name, + email: best.email, + deleted: best.deleted, + isBot: best.isBot, + note: matches.length > 1 ? "multiple matches; chose best" : undefined, + }); + continue; + } + } + if (parsed.name) { + const target = parsed.name.toLowerCase(); + const matches = users.filter((user) => { + const candidates = [ + user.name, + user.displayName, + user.realName, + ] + .map((value) => value?.toLowerCase()) + .filter(Boolean) as string[]; + return candidates.includes(target); + }); + if (matches.length > 0) { + const scored = matches + .map((user) => ({ user, score: scoreSlackUser(user, parsed) })) + .sort((a, b) => b.score - a.score); + const best = scored[0]?.user ?? matches[0]; + results.push({ + input, + resolved: true, + id: best.id, + name: best.displayName ?? best.realName ?? best.name, + email: best.email, + deleted: best.deleted, + isBot: best.isBot, + note: matches.length > 1 ? "multiple matches; chose best" : undefined, + }); + continue; + } + } + + results.push({ input, resolved: false }); + } + + return results; +} diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index cd9df3742..0bf5e7c28 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -243,7 +243,8 @@ export const registerTelegramHandlers = ({ return; } } - const groupPolicy = telegramCfg.groupPolicy ?? "open"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; if (groupPolicy === "disabled") { logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); return; @@ -430,7 +431,8 @@ export const registerTelegramHandlers = ({ // - "open": groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom - const groupPolicy = telegramCfg.groupPolicy ?? "open"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; if (groupPolicy === "disabled") { logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); return; diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 002d873b4..06c84efe9 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -163,7 +163,8 @@ export const registerTelegramNativeCommands = ({ } if (isGroup && useAccessGroups) { - const groupPolicy = telegramCfg.groupPolicy ?? "open"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; if (groupPolicy === "disabled") { await bot.api.sendMessage(chatId, "Telegram group commands are disabled."); return; diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 458e8422c..891712015 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -78,7 +78,8 @@ export async function checkInboundAccessControl(params: { // - "open": groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom - const groupPolicy = account.groupPolicy ?? "open"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "open"; if (params.group && groupPolicy === "disabled") { logVerbose("Blocked group message (groupPolicy: disabled)"); return {