feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields - Implement token routing: reads prefer user token, writes use bot token - Add tests for token routing logic - Update documentation with required OAuth scopes User tokens enable reading DMs and private channels without requiring bot membership. The userTokenReadOnly flag (true by default) ensures the user token can only be used for reads, preventing accidental sends as the user. Required user token scopes: - channels:history, channels:read - groups:history, groups:read - im:history, im:read - mpim:history, mpim:read - users:read, reactions:read, pins:read, emoji:read, search:read
This commit is contained in:
2
Peekaboo
2
Peekaboo
Submodule Peekaboo updated: 95ad7532c1...c1243a7978
@@ -27,17 +27,17 @@ Minimal config:
|
|||||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
4) **Event Subscriptions** → enable events and subscribe to:
|
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
|
||||||
|
5) **Event Subscriptions** → enable events and subscribe to:
|
||||||
- `message.*` (includes edits/deletes/thread broadcasts)
|
- `message.*` (includes edits/deletes/thread broadcasts)
|
||||||
- `app_mention`
|
- `app_mention`
|
||||||
- `reaction_added`, `reaction_removed`
|
- `reaction_added`, `reaction_removed`
|
||||||
- `member_joined_channel`, `member_left_channel`
|
- `member_joined_channel`, `member_left_channel`
|
||||||
- `channel_id_changed`
|
|
||||||
- `channel_rename`
|
- `channel_rename`
|
||||||
- `pin_added`, `pin_removed`
|
- `pin_added`, `pin_removed`
|
||||||
5) Invite the bot to channels you want it to read.
|
6) Invite the bot to channels you want it to read.
|
||||||
6) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
|
7) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
|
||||||
7) App Home → enable the **Messages Tab** so users can DM the bot.
|
8) App Home → enable the **Messages Tab** so users can DM the bot.
|
||||||
|
|
||||||
Use the manifest below so scopes and events stay in sync.
|
Use the manifest below so scopes and events stay in sync.
|
||||||
|
|
||||||
@@ -63,27 +63,59 @@ Or via config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## History context
|
## User token (optional)
|
||||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||||
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
|
prefer the user token when present, and writes still use the bot token unless
|
||||||
|
you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays
|
||||||
|
preferred for writes when it is available.
|
||||||
|
|
||||||
## Config writes
|
User tokens are configured in the config file (no env var support). For
|
||||||
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
|
multi-account, set `channels.slack.accounts.<id>.userToken`.
|
||||||
|
|
||||||
This happens when:
|
Example with bot + app + user tokens:
|
||||||
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
|
|
||||||
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
|
|
||||||
|
|
||||||
Disable with:
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: { slack: { configWrites: false } }
|
channels: {
|
||||||
|
slack: {
|
||||||
|
enabled: true,
|
||||||
|
appToken: "xapp-...",
|
||||||
|
botToken: "xoxb-...",
|
||||||
|
userToken: "xoxp-..."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example with userTokenReadOnly explicitly set (allow user token writes):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
enabled: true,
|
||||||
|
appToken: "xapp-...",
|
||||||
|
botToken: "xoxb-...",
|
||||||
|
userToken: "xoxp-...",
|
||||||
|
userTokenReadOnly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token usage
|
||||||
|
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||||
|
search) prefer the user token when configured, otherwise the bot token.
|
||||||
|
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||||
|
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||||
|
no bot token is available, Clawdbot falls back to the user token.
|
||||||
|
|
||||||
|
## History context
|
||||||
|
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||||
|
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
|
|
||||||
## Manifest (optional)
|
## Manifest (optional)
|
||||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||||
|
user scopes if you plan to configure a user token.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -133,6 +165,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
|||||||
"commands",
|
"commands",
|
||||||
"files:read",
|
"files:read",
|
||||||
"files:write"
|
"files:write"
|
||||||
|
],
|
||||||
|
"user": [
|
||||||
|
"channels:history",
|
||||||
|
"channels:read",
|
||||||
|
"groups:history",
|
||||||
|
"groups:read",
|
||||||
|
"im:history",
|
||||||
|
"im:read",
|
||||||
|
"mpim:history",
|
||||||
|
"mpim:read",
|
||||||
|
"users:read",
|
||||||
|
"reactions:read",
|
||||||
|
"pins:read",
|
||||||
|
"emoji:read",
|
||||||
|
"search:read"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -149,7 +196,6 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
|||||||
"reaction_removed",
|
"reaction_removed",
|
||||||
"member_joined_channel",
|
"member_joined_channel",
|
||||||
"member_left_channel",
|
"member_left_channel",
|
||||||
"channel_id_changed",
|
|
||||||
"channel_rename",
|
"channel_rename",
|
||||||
"pin_added",
|
"pin_added",
|
||||||
"pin_removed"
|
"pin_removed"
|
||||||
@@ -166,7 +212,7 @@ Slack's Conversations API is type-scoped: you only need the scopes for the
|
|||||||
conversation types you actually touch (channels, groups, im, mpim). See
|
conversation types you actually touch (channels, groups, im, mpim). See
|
||||||
https://api.channels.slack.com/docs/conversations-api for the overview.
|
https://api.channels.slack.com/docs/conversations-api for the overview.
|
||||||
|
|
||||||
### Required scopes
|
### Bot token scopes (required)
|
||||||
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
||||||
https://api.channels.slack.com/methods/chat.postMessage
|
https://api.channels.slack.com/methods/chat.postMessage
|
||||||
- `im:write` (open DMs via `conversations.open` for user DMs)
|
- `im:write` (open DMs via `conversations.open` for user DMs)
|
||||||
@@ -188,6 +234,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview.
|
|||||||
- `files:write` (uploads via `files.uploadV2`)
|
- `files:write` (uploads via `files.uploadV2`)
|
||||||
https://api.channels.slack.com/messaging/files/uploading
|
https://api.channels.slack.com/messaging/files/uploading
|
||||||
|
|
||||||
|
### User token scopes (optional, read-only by default)
|
||||||
|
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
||||||
|
|
||||||
|
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||||
|
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
||||||
|
- `users:read`
|
||||||
|
- `reactions:read`
|
||||||
|
- `pins:read`
|
||||||
|
- `emoji:read`
|
||||||
|
- `search:read`
|
||||||
|
|
||||||
### Not needed today (but likely future)
|
### Not needed today (but likely future)
|
||||||
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
||||||
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
||||||
@@ -269,11 +326,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
|||||||
|
|
||||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||||
|
|
||||||
### Thread session isolation
|
|
||||||
Slack thread sessions are isolated by default. Configure with:
|
|
||||||
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
|
|
||||||
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
|
|
||||||
|
|
||||||
### Manual threading tags
|
### Manual threading tags
|
||||||
For fine-grained control, use these tags in agent responses:
|
For fine-grained control, use these tags in agent responses:
|
||||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||||
@@ -320,6 +372,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
|||||||
| memberInfo | enabled | Member info |
|
| memberInfo | enabled | Member info |
|
||||||
| emojiList | enabled | Custom emoji list |
|
| emojiList | enabled | Custom emoji list |
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
- Writes default to the bot token so state-changing actions stay scoped to the
|
||||||
|
app's bot permissions and identity.
|
||||||
|
- Setting `userTokenReadOnly: false` allows the user token to be used for write
|
||||||
|
operations when a bot token is unavailable, which means actions run with the
|
||||||
|
installing user's access. Treat the user token as highly privileged and keep
|
||||||
|
action gates and allowlists tight.
|
||||||
|
- If you enable user-token writes, make sure the user token includes the write
|
||||||
|
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
|
||||||
|
`files:write`) or those operations will fail.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
|
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
|
||||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||||
@@ -327,5 +390,4 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
|||||||
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
||||||
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||||
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
|
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
|
||||||
- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`.
|
|
||||||
- Attachments are downloaded to the media store when permitted and under the size limit.
|
- Attachments are downloaded to the media store when permitted and under the size limit.
|
||||||
|
|||||||
@@ -361,4 +361,48 @@ describe("handleSlackAction", () => {
|
|||||||
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
|
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
|
||||||
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
|
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses user token for reads when available", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
readSlackMessages.mockClear();
|
||||||
|
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
||||||
|
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
|
||||||
|
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
|
||||||
|
expect(opts?.token).toBe("xoxp-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to bot token for reads when user token missing", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { slack: { botToken: "xoxb-1" } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
readSlackMessages.mockClear();
|
||||||
|
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
||||||
|
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
|
||||||
|
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
|
||||||
|
expect(opts?.token).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses bot token for writes when userTokenReadOnly is true", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
sendSlackMessage.mockClear();
|
||||||
|
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
|
||||||
|
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
|
||||||
|
expect(opts?.token).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows user token writes when bot token is missing", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: { userToken: "xoxp-1", userTokenReadOnly: false },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
sendSlackMessage.mockClear();
|
||||||
|
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
|
||||||
|
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
|
||||||
|
expect(opts?.token).toBe("xoxp-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,10 +78,32 @@ export async function handleSlackAction(
|
|||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
const accountId = readStringParam(params, "accountId");
|
const accountId = readStringParam(params, "accountId");
|
||||||
const accountOpts = accountId ? { accountId } : undefined;
|
|
||||||
const account = resolveSlackAccount({ cfg, accountId });
|
const account = resolveSlackAccount({ cfg, accountId });
|
||||||
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
|
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
|
||||||
const isActionEnabled = createActionGate(actionConfig);
|
const isActionEnabled = createActionGate(actionConfig);
|
||||||
|
const userToken = account.config.userToken?.trim() || undefined;
|
||||||
|
const botToken = account.botToken?.trim();
|
||||||
|
const allowUserWrites = account.config.userTokenReadOnly === false;
|
||||||
|
|
||||||
|
// Choose the most appropriate token for Slack read/write operations.
|
||||||
|
const getTokenForOperation = (operation: "read" | "write") => {
|
||||||
|
if (operation === "read") return userToken ?? botToken;
|
||||||
|
if (!allowUserWrites) return botToken;
|
||||||
|
return botToken ?? userToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildActionOpts = (operation: "read" | "write") => {
|
||||||
|
const token = getTokenForOperation(operation);
|
||||||
|
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||||
|
if (!accountId && !tokenOverride) return undefined;
|
||||||
|
return {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
|
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readOpts = buildActionOpts("read");
|
||||||
|
const writeOpts = buildActionOpts("write");
|
||||||
|
|
||||||
if (reactionsActions.has(action)) {
|
if (reactionsActions.has(action)) {
|
||||||
if (!isActionEnabled("reactions")) {
|
if (!isActionEnabled("reactions")) {
|
||||||
@@ -94,28 +116,28 @@ export async function handleSlackAction(
|
|||||||
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
|
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
|
||||||
});
|
});
|
||||||
if (remove) {
|
if (remove) {
|
||||||
if (accountOpts) {
|
if (writeOpts) {
|
||||||
await removeSlackReaction(channelId, messageId, emoji, accountOpts);
|
await removeSlackReaction(channelId, messageId, emoji, writeOpts);
|
||||||
} else {
|
} else {
|
||||||
await removeSlackReaction(channelId, messageId, emoji);
|
await removeSlackReaction(channelId, messageId, emoji);
|
||||||
}
|
}
|
||||||
return jsonResult({ ok: true, removed: emoji });
|
return jsonResult({ ok: true, removed: emoji });
|
||||||
}
|
}
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
const removed = accountOpts
|
const removed = writeOpts
|
||||||
? await removeOwnSlackReactions(channelId, messageId, accountOpts)
|
? await removeOwnSlackReactions(channelId, messageId, writeOpts)
|
||||||
: await removeOwnSlackReactions(channelId, messageId);
|
: await removeOwnSlackReactions(channelId, messageId);
|
||||||
return jsonResult({ ok: true, removed });
|
return jsonResult({ ok: true, removed });
|
||||||
}
|
}
|
||||||
if (accountOpts) {
|
if (writeOpts) {
|
||||||
await reactSlackMessage(channelId, messageId, emoji, accountOpts);
|
await reactSlackMessage(channelId, messageId, emoji, writeOpts);
|
||||||
} else {
|
} else {
|
||||||
await reactSlackMessage(channelId, messageId, emoji);
|
await reactSlackMessage(channelId, messageId, emoji);
|
||||||
}
|
}
|
||||||
return jsonResult({ ok: true, added: emoji });
|
return jsonResult({ ok: true, added: emoji });
|
||||||
}
|
}
|
||||||
const reactions = accountOpts
|
const reactions = readOpts
|
||||||
? await listSlackReactions(channelId, messageId, accountOpts)
|
? await listSlackReactions(channelId, messageId, readOpts)
|
||||||
: await listSlackReactions(channelId, messageId);
|
: await listSlackReactions(channelId, messageId);
|
||||||
return jsonResult({ ok: true, reactions });
|
return jsonResult({ ok: true, reactions });
|
||||||
}
|
}
|
||||||
@@ -135,7 +157,7 @@ export async function handleSlackAction(
|
|||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
const result = await sendSlackMessage(to, content, {
|
const result = await sendSlackMessage(to, content, {
|
||||||
accountId: accountId ?? undefined,
|
...writeOpts,
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
threadTs: threadTs ?? undefined,
|
threadTs: threadTs ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -162,8 +184,8 @@ export async function handleSlackAction(
|
|||||||
const content = readStringParam(params, "content", {
|
const content = readStringParam(params, "content", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
if (accountOpts) {
|
if (writeOpts) {
|
||||||
await editSlackMessage(channelId, messageId, content, accountOpts);
|
await editSlackMessage(channelId, messageId, content, writeOpts);
|
||||||
} else {
|
} else {
|
||||||
await editSlackMessage(channelId, messageId, content);
|
await editSlackMessage(channelId, messageId, content);
|
||||||
}
|
}
|
||||||
@@ -176,8 +198,8 @@ export async function handleSlackAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
if (accountOpts) {
|
if (writeOpts) {
|
||||||
await deleteSlackMessage(channelId, messageId, accountOpts);
|
await deleteSlackMessage(channelId, messageId, writeOpts);
|
||||||
} else {
|
} else {
|
||||||
await deleteSlackMessage(channelId, messageId);
|
await deleteSlackMessage(channelId, messageId);
|
||||||
}
|
}
|
||||||
@@ -193,7 +215,7 @@ export async function handleSlackAction(
|
|||||||
const before = readStringParam(params, "before");
|
const before = readStringParam(params, "before");
|
||||||
const after = readStringParam(params, "after");
|
const after = readStringParam(params, "after");
|
||||||
const result = await readSlackMessages(channelId, {
|
const result = await readSlackMessages(channelId, {
|
||||||
accountId: accountId ?? undefined,
|
...readOpts,
|
||||||
limit,
|
limit,
|
||||||
before: before ?? undefined,
|
before: before ?? undefined,
|
||||||
after: after ?? undefined,
|
after: after ?? undefined,
|
||||||
@@ -220,8 +242,8 @@ export async function handleSlackAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
if (accountOpts) {
|
if (writeOpts) {
|
||||||
await pinSlackMessage(channelId, messageId, accountOpts);
|
await pinSlackMessage(channelId, messageId, writeOpts);
|
||||||
} else {
|
} else {
|
||||||
await pinSlackMessage(channelId, messageId);
|
await pinSlackMessage(channelId, messageId);
|
||||||
}
|
}
|
||||||
@@ -231,15 +253,15 @@ export async function handleSlackAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
if (accountOpts) {
|
if (writeOpts) {
|
||||||
await unpinSlackMessage(channelId, messageId, accountOpts);
|
await unpinSlackMessage(channelId, messageId, writeOpts);
|
||||||
} else {
|
} else {
|
||||||
await unpinSlackMessage(channelId, messageId);
|
await unpinSlackMessage(channelId, messageId);
|
||||||
}
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
const pins = accountOpts
|
const pins = writeOpts
|
||||||
? await listSlackPins(channelId, accountOpts)
|
? await listSlackPins(channelId, readOpts)
|
||||||
: await listSlackPins(channelId);
|
: await listSlackPins(channelId);
|
||||||
const normalizedPins = pins.map((pin) => {
|
const normalizedPins = pins.map((pin) => {
|
||||||
const message = pin.message
|
const message = pin.message
|
||||||
@@ -258,8 +280,8 @@ export async function handleSlackAction(
|
|||||||
throw new Error("Slack member info is disabled.");
|
throw new Error("Slack member info is disabled.");
|
||||||
}
|
}
|
||||||
const userId = readStringParam(params, "userId", { required: true });
|
const userId = readStringParam(params, "userId", { required: true });
|
||||||
const info = accountOpts
|
const info = writeOpts
|
||||||
? await getSlackMemberInfo(userId, accountOpts)
|
? await getSlackMemberInfo(userId, readOpts)
|
||||||
: await getSlackMemberInfo(userId);
|
: await getSlackMemberInfo(userId);
|
||||||
return jsonResult({ ok: true, info });
|
return jsonResult({ ok: true, info });
|
||||||
}
|
}
|
||||||
@@ -268,7 +290,7 @@ export async function handleSlackAction(
|
|||||||
if (!isActionEnabled("emojiList")) {
|
if (!isActionEnabled("emojiList")) {
|
||||||
throw new Error("Slack emoji list is disabled.");
|
throw new Error("Slack emoji list is disabled.");
|
||||||
}
|
}
|
||||||
const emojis = accountOpts ? await listSlackEmojis(accountOpts) : await listSlackEmojis();
|
const emojis = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis();
|
||||||
return jsonResult({ ok: true, emojis });
|
return jsonResult({ ok: true, emojis });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
|
||||||
|
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
|
||||||
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
|
listEnabledSlackAccounts,
|
||||||
listSlackAccountIds,
|
listSlackAccountIds,
|
||||||
type ResolvedSlackAccount,
|
type ResolvedSlackAccount,
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
@@ -21,11 +25,23 @@ import {
|
|||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
} from "./setup-helpers.js";
|
} from "./setup-helpers.js";
|
||||||
import { createSlackActions } from "./slack.actions.js";
|
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
||||||
import type { ChannelPlugin } from "./types.js";
|
|
||||||
|
|
||||||
const meta = getChatChannelMeta("slack");
|
const meta = getChatChannelMeta("slack");
|
||||||
|
|
||||||
|
// Select the appropriate Slack token for read/write operations.
|
||||||
|
function getTokenForOperation(
|
||||||
|
account: ResolvedSlackAccount,
|
||||||
|
operation: "read" | "write",
|
||||||
|
): string | undefined {
|
||||||
|
const userToken = account.config.userToken?.trim() || undefined;
|
||||||
|
const botToken = account.botToken?.trim();
|
||||||
|
const allowUserWrites = account.config.userTokenReadOnly === false;
|
||||||
|
if (operation === "read") return userToken ?? botToken;
|
||||||
|
if (!allowUserWrites) return botToken;
|
||||||
|
return botToken ?? userToken;
|
||||||
|
}
|
||||||
|
|
||||||
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||||
id: "slack",
|
id: "slack",
|
||||||
meta: {
|
meta: {
|
||||||
@@ -36,7 +52,21 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
idLabel: "slackUserId",
|
idLabel: "slackUserId",
|
||||||
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
|
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
|
||||||
notifyApproval: async ({ id }) => {
|
notifyApproval: async ({ id }) => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const account = resolveSlackAccount({
|
||||||
|
cfg,
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
});
|
||||||
|
const token = getTokenForOperation(account, "write");
|
||||||
|
const botToken = account.botToken?.trim();
|
||||||
|
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||||
|
if (tokenOverride) {
|
||||||
|
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, {
|
||||||
|
token: tokenOverride,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -139,7 +169,197 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeSlackMessagingTarget,
|
normalizeTarget: normalizeSlackMessagingTarget,
|
||||||
},
|
},
|
||||||
actions: createSlackActions(meta.id),
|
actions: {
|
||||||
|
listActions: ({ cfg }) => {
|
||||||
|
const accounts = listEnabledSlackAccounts(cfg).filter(
|
||||||
|
(account) => account.botTokenSource !== "none",
|
||||||
|
);
|
||||||
|
if (accounts.length === 0) return [];
|
||||||
|
const isActionEnabled = (key: string, defaultValue = true) => {
|
||||||
|
for (const account of accounts) {
|
||||||
|
const gate = createActionGate(
|
||||||
|
(account.actions ?? cfg.channels?.slack?.actions) as Record<
|
||||||
|
string,
|
||||||
|
boolean | undefined
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
if (gate(key, defaultValue)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||||
|
if (isActionEnabled("reactions")) {
|
||||||
|
actions.add("react");
|
||||||
|
actions.add("reactions");
|
||||||
|
}
|
||||||
|
if (isActionEnabled("messages")) {
|
||||||
|
actions.add("read");
|
||||||
|
actions.add("edit");
|
||||||
|
actions.add("delete");
|
||||||
|
}
|
||||||
|
if (isActionEnabled("pins")) {
|
||||||
|
actions.add("pin");
|
||||||
|
actions.add("unpin");
|
||||||
|
actions.add("list-pins");
|
||||||
|
}
|
||||||
|
if (isActionEnabled("memberInfo")) actions.add("member-info");
|
||||||
|
if (isActionEnabled("emojiList")) actions.add("emoji-list");
|
||||||
|
return Array.from(actions);
|
||||||
|
},
|
||||||
|
extractToolSend: ({ args }) => {
|
||||||
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
|
if (action !== "sendMessage") return null;
|
||||||
|
const to = typeof args.to === "string" ? args.to : undefined;
|
||||||
|
if (!to) return null;
|
||||||
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||||
|
return { to, accountId };
|
||||||
|
},
|
||||||
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||||
|
const resolveChannelId = () =>
|
||||||
|
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
|
||||||
|
|
||||||
|
if (action === "send") {
|
||||||
|
const to = readStringParam(params, "to", { required: true });
|
||||||
|
const content = readStringParam(params, "message", {
|
||||||
|
required: true,
|
||||||
|
allowEmpty: true,
|
||||||
|
});
|
||||||
|
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||||
|
const threadId = readStringParam(params, "threadId");
|
||||||
|
const replyTo = readStringParam(params, "replyTo");
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "sendMessage",
|
||||||
|
to,
|
||||||
|
content,
|
||||||
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
threadTs: threadId ?? replyTo ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
toolContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "react") {
|
||||||
|
const messageId = readStringParam(params, "messageId", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||||
|
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "react",
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
messageId,
|
||||||
|
emoji,
|
||||||
|
remove,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "reactions") {
|
||||||
|
const messageId = readStringParam(params, "messageId", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const limit = readNumberParam(params, "limit", { integer: true });
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "reactions",
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
messageId,
|
||||||
|
limit,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "read") {
|
||||||
|
const limit = readNumberParam(params, "limit", { integer: true });
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "readMessages",
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
limit,
|
||||||
|
before: readStringParam(params, "before"),
|
||||||
|
after: readStringParam(params, "after"),
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "edit") {
|
||||||
|
const messageId = readStringParam(params, "messageId", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const content = readStringParam(params, "message", { required: true });
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "editMessage",
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
messageId,
|
||||||
|
content,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "delete") {
|
||||||
|
const messageId = readStringParam(params, "messageId", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "deleteMessage",
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
messageId,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||||
|
const messageId =
|
||||||
|
action === "list-pins"
|
||||||
|
? undefined
|
||||||
|
: readStringParam(params, "messageId", { required: true });
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action:
|
||||||
|
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
messageId,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "member-info") {
|
||||||
|
const userId = readStringParam(params, "userId", { required: true });
|
||||||
|
return await handleSlackAction(
|
||||||
|
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "emoji-list") {
|
||||||
|
return await handleSlackAction(
|
||||||
|
{ action: "emojiList", accountId: accountId ?? undefined },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Action ${action} is not supported for provider ${meta.id}.`);
|
||||||
|
},
|
||||||
|
},
|
||||||
setup: {
|
setup: {
|
||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
applyAccountName: ({ cfg, accountId, name }) =>
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
@@ -225,20 +445,30 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
}
|
}
|
||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
|
||||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||||
|
const account = resolveSlackAccount({ cfg, accountId });
|
||||||
|
const token = getTokenForOperation(account, "write");
|
||||||
|
const botToken = account.botToken?.trim();
|
||||||
|
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
threadTs: replyToId ?? undefined,
|
threadTs: replyToId ?? undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||||
});
|
});
|
||||||
return { channel: "slack", ...result };
|
return { channel: "slack", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
|
||||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||||
|
const account = resolveSlackAccount({ cfg, accountId });
|
||||||
|
const token = getTokenForOperation(account, "write");
|
||||||
|
const botToken = account.botToken?.trim();
|
||||||
|
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
threadTs: replyToId ?? undefined,
|
threadTs: replyToId ?? undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||||
});
|
});
|
||||||
return { channel: "slack", ...result };
|
return { channel: "slack", ...result };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"channels.discord.token": "Discord Bot Token",
|
"channels.discord.token": "Discord Bot Token",
|
||||||
"channels.slack.botToken": "Slack Bot Token",
|
"channels.slack.botToken": "Slack Bot Token",
|
||||||
"channels.slack.appToken": "Slack App Token",
|
"channels.slack.appToken": "Slack App Token",
|
||||||
|
"channels.slack.userToken": "Slack User Token",
|
||||||
|
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
||||||
"channels.slack.thread.historyScope": "Slack Thread History Scope",
|
"channels.slack.thread.historyScope": "Slack Thread History Scope",
|
||||||
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
||||||
"channels.signal.account": "Signal Account",
|
"channels.signal.account": "Signal Account",
|
||||||
|
|||||||
37
src/config/slack-token-validation.test.ts
Normal file
37
src/config/slack-token-validation.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { validateConfigObject } from "./config.js";
|
||||||
|
|
||||||
|
describe("Slack token config fields", () => {
|
||||||
|
it("accepts user token config fields", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
botToken: "xoxb-any",
|
||||||
|
appToken: "xapp-any",
|
||||||
|
userToken: "xoxp-any",
|
||||||
|
userTokenReadOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts account-level user token config", () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
accounts: {
|
||||||
|
work: {
|
||||||
|
botToken: "xoxb-any",
|
||||||
|
appToken: "xapp-any",
|
||||||
|
userToken: "xoxp-any",
|
||||||
|
userTokenReadOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,6 +80,9 @@ export type SlackAccountConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
appToken?: string;
|
appToken?: string;
|
||||||
|
userToken?: string;
|
||||||
|
/** If true, restrict user token to read operations only. Default: true. */
|
||||||
|
userTokenReadOnly?: boolean;
|
||||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||||
allowBots?: boolean;
|
allowBots?: boolean;
|
||||||
/** Default mention requirement for channel messages (default: true). */
|
/** Default mention requirement for channel messages (default: true). */
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ export const SlackAccountSchema = z.object({
|
|||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
appToken: z.string().optional(),
|
appToken: z.string().optional(),
|
||||||
|
userToken: z.string().optional(),
|
||||||
|
userTokenReadOnly: z.boolean().optional().default(true),
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
|
|||||||
Reference in New Issue
Block a user