feat(channels): add resolve command + defaults

This commit is contained in:
Peter Steinberger
2026-01-18 00:41:57 +00:00
parent b543339373
commit c7ea47e886
60 changed files with 4418 additions and 101 deletions

View File

@@ -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:<agentId>:discord:slash:<userId>`) 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 cant 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 didnt 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.<id>.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.<id>.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 cant verify permissions.
- **DMs dont work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you havent been approved yet (`channels.discord.dm.policy="pairing"`).
@@ -362,6 +365,10 @@ Allowlist matching notes:
- Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.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 Clawdbots chat commands.

View File

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

View File

@@ -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 mentiongated 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 (mentiongated).
- 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).

View File

@@ -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 <code>`.
- 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.<id>` or `channels.slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.

View File

@@ -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 <code>`
## 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:

View File

@@ -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:<id>` 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.

View File

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

View File

@@ -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 providers `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`)

View File

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

View File

@@ -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<ResolvedMatrixAccount> = {
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<ResolvedMatrixAccount> = {
.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: {

View File

@@ -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<T>(params: {
homeserver: string;
path: string;
accessToken: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<T> {
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<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
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<string | null> {
try {
const res = await fetchMatrixJson<MatrixAliasLookup>({
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<string | null> {
try {
const res = await fetchMatrixJson<MatrixRoomNameState>({
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<ChannelDirectoryEntry[]> {
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<MatrixJoinedRoomsResponse>({
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;
}

View File

@@ -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<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
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";

View File

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

View File

@@ -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<ResolvedMSTeamsAccount> = {
},
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<ResolvedMSTeamsAccount> = {
.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 }) => {

View File

@@ -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<T> = { 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<T>(params: {
token: string;
path: string;
headers?: Record<string, string>;
}): Promise<T> {
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<string> {
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<GraphGroup[]> {
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<GraphResponse<GraphGroup>>({ token, path });
return res.value ?? [];
}
async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
return res.value ?? [];
}
export async function listMSTeamsDirectoryPeersLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
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<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
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<ChannelDirectoryEntry[]> {
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;
}

View File

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

View File

@@ -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<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
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<MonitorMSTeamsResult> {
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;

View File

@@ -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<void
);
}
function setMSTeamsGroupPolicy(
cfg: ClawdbotConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
groupPolicy,
},
},
};
}
function setMSTeamsTeamsAllowlist(
cfg: ClawdbotConfig,
entries: Array<{ teamKey: string; channelKey?: string }>,
): ClawdbotConfig {
const baseTeams = cfg.channels?.msteams?.teams ?? {};
const teams: Record<string, { channels?: Record<string, unknown> }> = { ...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,

View File

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

View File

@@ -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 = {

View File

@@ -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<T> = { 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<T>(params: {
token: string;
path: string;
headers?: Record<string, string>;
}): Promise<T> {
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<string> {
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<GraphGroup[]> {
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<GraphResponse<GraphGroup>>({ token, path });
return res.value ?? [];
}
async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
return res.value ?? [];
}
export async function resolveMSTeamsChannelAllowlist(params: {
cfg: unknown;
entries: string[];
}): Promise<MSTeamsChannelResolution[]> {
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<MSTeamsUserResolution[]> {
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<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
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;
}

View File

@@ -324,6 +324,73 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
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<ZcaFriend[]>(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<ZcaGroup[]>(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, ""),

View File

@@ -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<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
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<T>(
items: T[],
nameFn: (item: T) => string | undefined,
): Map<string, T[]> {
const index = new Map<string, T[]>();
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<string, { allow?: boolean; enabled?: boolean }>;
}): 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<ZalouserMonitorResult> {
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<typeof setTimeout> | 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<ZcaFriend[]>(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<ZcaGroup[]>(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) {

View File

@@ -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<Array<{ input: string; resolved: boolean; id?: string }>> {
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<ZcaGroup[]>(result.stdout) ?? []).filter(
(group) => Boolean(group.groupId),
);
const byName = new Map<string, ZcaGroup[]>();
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 };
},
};

View File

@@ -77,6 +77,8 @@ export type ZalouserAccountConfig = {
profile?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
messagePrefix?: string;
};
@@ -87,6 +89,8 @@ export type ZalouserConfig = {
defaultAccount?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
messagePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};

View File

@@ -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<ResolvedDiscordAccount> = {
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<ResolvedDiscordAccount> = {
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,

View File

@@ -95,8 +95,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
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.`,

View File

@@ -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<ChannelAccessPolicy> {
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<string[]> {
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 };
}

View File

@@ -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<void> {
);
}
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<string, { channels?: Record<string, { allow: boolean }> }> = {
...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,

View File

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

View File

@@ -108,8 +108,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
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.`,

View File

@@ -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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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 }) => {

View File

@@ -141,8 +141,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
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;

View File

@@ -263,6 +263,26 @@ export type ChannelDirectoryAdapter = {
}) => Promise<ChannelDirectoryEntry[]>;
};
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<ChannelResolveResult[]>;
};
export type ChannelElevatedAdapter = {
allowFromFallback?: (params: {
cfg: ClawdbotConfig;

View File

@@ -236,6 +236,7 @@ export type ChannelDirectoryEntry = {
name?: string;
handle?: string;
avatarUrl?: string;
rank?: number;
raw?: unknown;
};

View File

@@ -4,6 +4,7 @@ import type {
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelDirectoryAdapter,
ChannelResolverAdapter,
ChannelElevatedAdapter,
ChannelGatewayAdapter,
ChannelGroupAdapter,
@@ -68,6 +69,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;
directory?: ChannelDirectoryAdapter;
resolver?: ChannelResolverAdapter;
actions?: ChannelMessageActionAdapter;
heartbeat?: ChannelHeartbeatAdapter;
// Channel-owned agent tools (login flows, etc.).

View File

@@ -9,6 +9,9 @@ export type {
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelDirectoryAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelResolverAdapter,
ChannelElevatedAdapter,
ChannelGatewayAdapter,
ChannelGatewayContext,

View File

@@ -149,8 +149,9 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
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;

View File

@@ -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...>", "Entries to resolve (names or ids)")
.option("--channel <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (accountId)")
.option("--kind <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")

View File

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

View File

@@ -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<ChannelResolveKind, string[]>();
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})` : ""}`,
),
);
}
}
}

View File

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

View File

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

View File

@@ -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<T>(path: string, token: string): Promise<T> {
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<ChannelDirectoryEntry[]> {
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<DiscordGuild[]>("/users/@me/guilds", token);
const rows: ChannelDirectoryEntry[] = [];
for (const guild of guilds) {
const channels = await fetchDiscord<DiscordChannel[]>(`/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<ChannelDirectoryEntry[]> {
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<DiscordGuild[]>("/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<DiscordMember[]>(
`/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;
}

View File

@@ -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<string, unknown>) {
return `${sample.join(", ")}${suffix}`;
}
function mergeAllowlist(params: {
existing?: Array<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
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<string>();
for (const guild of Object.values(guildEntries)) {
if (!guild || typeof guild !== "object") continue;
const users = (guild as { users?: Array<string | number> }).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<string, unknown> }).channels ?? {};
for (const channel of Object.values(channels)) {
if (!channel || typeof channel !== "object") continue;
const channelUsers = (channel as { users?: Array<string | number> }).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<string, unknown>;
const users = (guildConfig as { users?: Array<string | number> }).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<string, unknown> }).channels ?? {};
if (channels && typeof channels === "object") {
const nextChannels: Record<string, unknown> = { ...channels };
for (const [channelKey, channelConfig] of Object.entries(channels)) {
if (!channelConfig || typeof channelConfig !== "object") continue;
const channelUsers = (channelConfig as { users?: Array<string | number> }).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"}`,

View File

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

View File

@@ -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<T>(
path: string,
token: string,
fetcher: typeof fetch,
): Promise<T> {
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<DiscordGuildSummary[]> {
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
"/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<DiscordChannelSummary[]> {
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<DiscordChannelSummary | null> {
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<DiscordChannelResolution[]> {
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<string, Promise<DiscordChannelSummary[]>>();
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;
}

View File

@@ -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<T>(path: string, token: string, fetcher: typeof fetch): Promise<T> {
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<DiscordGuildSummary[]> {
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
"/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<DiscordUserResolution[]> {
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<DiscordMember[]>(
`/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;
}

View File

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

View File

@@ -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<ResolveMessagingTargetResult> {
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),

View File

@@ -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<string, unknown> | 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<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
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<string, unknown> | undefined;
const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0;
const groupAccessPossible =

View File

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

163
src/slack/directory-live.ts Normal file
View File

@@ -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<ChannelDirectoryEntry[]> {
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<ChannelDirectoryEntry[]> {
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;
}

View File

@@ -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<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
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<string>();
for (const channel of Object.values(channelsConfig)) {
if (!channel || typeof channel !== "object") continue;
const users = (channel as { users?: Array<string | number> }).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<string | number> }).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 ?? [];

View File

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

View File

@@ -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<SlackChannelLookup[]> {
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<SlackChannelResolution[]> {
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;
}

182
src/slack/resolve-users.ts Normal file
View File

@@ -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<SlackUserLookup[]> {
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<SlackUserResolution[]> {
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;
}

View File

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

View File

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

View File

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