mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat(channels): add resolve command + defaults
This commit is contained in:
@@ -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 can’t search members, use ids or `<@id>` mentions.
|
||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||
|
||||
@@ -193,8 +193,11 @@ Notes:
|
||||
- Your config requires mentions and you didn’t mention it, or
|
||||
- Your guild/channel allowlist denies the channel/user.
|
||||
- **`requireMention: false` but still no replies**:
|
||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<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 can’t verify permissions.
|
||||
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
||||
|
||||
@@ -362,6 +365,10 @@ Allowlist matching notes:
|
||||
- Use `*` to allow any sender/channel.
|
||||
- When `guilds.<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 Clawdbot’s chat commands.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -76,12 +76,13 @@ Disable with:
|
||||
|
||||
**DM access**
|
||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
|
||||
|
||||
**Group access**
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
||||
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
|
||||
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
@@ -95,6 +96,32 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
**Teams + channel allowlist**
|
||||
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
|
||||
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
|
||||
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
|
||||
- The configure wizard accepts `Team/Channel` entries and stores them for you.
|
||||
- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
|
||||
and logs the mapping; unresolved entries are kept as typed.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
teams: {
|
||||
"My Team": {
|
||||
channels: {
|
||||
"General": { requireMention: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How it works
|
||||
1. Install the Microsoft Teams plugin.
|
||||
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -678,10 +678,11 @@ Notes:
|
||||
- `"open"`: groups bypass allowlists; mention-gating still applies.
|
||||
- `"disabled"`: block all group/room messages.
|
||||
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
|
||||
- `channels.defaults.groupPolicy` sets the default when a provider’s `groupPolicy` is unset.
|
||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
|
||||
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
|
||||
- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked.
|
||||
- Default is `groupPolicy: "allowlist"` (unless overridden by `channels.defaults.groupPolicy`); if no allowlist is configured, group messages are blocked.
|
||||
|
||||
### Multi-agent routing (`agents.list` + `bindings`)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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: {
|
||||
|
||||
175
extensions/matrix/src/directory-live.ts
Normal file
175
extensions/matrix/src/directory-live.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
179
extensions/msteams/src/directory-live.ts
Normal file
179
extensions/msteams/src/directory-live.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
223
extensions/msteams/src/resolve-allowlist.ts
Normal file
223
extensions/msteams/src/resolve-allowlist.ts
Normal 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;
|
||||
}
|
||||
@@ -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, ""),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
93
src/channels/plugins/onboarding/channel-access.ts
Normal file
93
src/channels/plugins/onboarding/channel-access.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -236,6 +236,7 @@ export type ChannelDirectoryEntry = {
|
||||
name?: string;
|
||||
handle?: string;
|
||||
avatarUrl?: string;
|
||||
rank?: number;
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -9,6 +9,9 @@ export type {
|
||||
ChannelCommandAdapter,
|
||||
ChannelConfigAdapter,
|
||||
ChannelDirectoryAdapter,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelResolverAdapter,
|
||||
ChannelElevatedAdapter,
|
||||
ChannelGatewayAdapter,
|
||||
ChannelGatewayContext,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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";
|
||||
|
||||
131
src/commands/channels/resolve.ts
Normal file
131
src/commands/channels/resolve.ts
Normal 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})` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
104
src/discord/directory-live.ts
Normal file
104
src/discord/directory-live.ts
Normal 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;
|
||||
}
|
||||
@@ -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"}`,
|
||||
|
||||
56
src/discord/resolve-channels.test.ts
Normal file
56
src/discord/resolve-channels.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
317
src/discord/resolve-channels.ts
Normal file
317
src/discord/resolve-channels.ts
Normal 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;
|
||||
}
|
||||
178
src/discord/resolve-users.ts
Normal file
178
src/discord/resolve-users.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
163
src/slack/directory-live.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
|
||||
43
src/slack/resolve-channels.test.ts
Normal file
43
src/slack/resolve-channels.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
src/slack/resolve-channels.ts
Normal file
121
src/slack/resolve-channels.ts
Normal 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
182
src/slack/resolve-users.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user