Skip to content

Commit 5c91bbb

Browse files
committed
feat(plugin-slack): role-aware outbound client routing — OWNER posts as user
Adds the runtime primitive PR #7868 unblocks: when a Slack account is configured with role: "OWNER" and a user token (xoxp-) is present, outbound chat.postMessage calls go through a second WebClient bound to the user token so the agent acts as the user. AGENT accounts continue to use the bot client (xoxb-), preserving all existing single-bot deployments unchanged. Changes: - accounts.ts: * SlackAccountConfig gains an optional `role` field. * ResolvedSlackAccount gains a required `role: ConnectorAccountRole`. * `resolveSlackAccount` reads role from per-account character.settings or the SLACK_ACCOUNT_ROLE env var (default-account only); falls back to "AGENT". Config role beats env role. * New `normalizeSlackAccountRole` helper accepts OWNER/AGENT/TEAM (mixed case, trimmed) and defaults unknown / non-string input to AGENT. - service.ts: * SlackAccountRuntime gains `userClient: WebClient | null`, constructed in `initializeAccount` only when `account.userToken` is set. The user client is outbound-only — no socket mode. * New private `getOutboundClient(accountId)` selects the user client when the account's role is OWNER and a user client exists; otherwise the bot client. Falls back to `getClientForAccount` when per-account state hasn't been initialised yet. * `sendMessage` switches from `getClientForAccount` to `getOutboundClient`. Other call sites stay on the bot client because Slack's user-scope grant for chat:write covers chat.postMessage only — broadening to reactions/pins/files requires additional user scopes and is left for a follow-up. - accounts.test.ts (new, 8 tests): * normalizeSlackAccountRole canonical / mixed-case / fallback paths. * resolveSlackAccount role resolution from config, env, default, and config-beats-env precedence. Out of scope (separate PR): - Hydration of `botToken` / `userToken` / `role` from ConnectorAccount records persisted by the OAuth callback (#7868). The credential-refs read path is not currently exposed by ConnectorAccountManager; adding it would mean a cross-plugin runtime change. Today admins who want OWNER routing must set the role + userToken in character.settings.slack.accounts.<id> (or via the SLACK_ACCOUNT_ROLE + SLACK_USER_TOKEN env pair for the default account). Once hydration lands, the OWNER routing in this PR picks up the OAuth-stored xoxp- automatically. Verification: - bunx tsc --noEmit -p plugins/plugin-slack/tsconfig.json: clean - bunx vitest run plugins/plugin-slack/src/: 17/17 (8 new + 9 existing)
1 parent e57124a commit 5c91bbb

3 files changed

Lines changed: 195 additions & 3 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { Character, IAgentRuntime } from "@elizaos/core";
2+
import { describe, expect, it, vi } from "vitest";
3+
import {
4+
normalizeSlackAccountRole,
5+
resolveSlackAccount,
6+
type SlackMultiAccountConfig,
7+
} from "./accounts";
8+
9+
function createRuntime(
10+
slackConfig?: SlackMultiAccountConfig,
11+
envOverrides?: Record<string, string | undefined>,
12+
): IAgentRuntime {
13+
const character: Partial<Character> = {
14+
settings: slackConfig ? { slack: slackConfig } : {},
15+
};
16+
const env = envOverrides ?? {};
17+
const runtime = {
18+
agentId: "agent-1",
19+
character: character as Character,
20+
getSetting: vi.fn((key: string) => env[key]),
21+
logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
22+
};
23+
return runtime as unknown as IAgentRuntime;
24+
}
25+
26+
describe("normalizeSlackAccountRole", () => {
27+
it("returns canonical OWNER / AGENT / TEAM for matching inputs", () => {
28+
expect(normalizeSlackAccountRole("OWNER")).toBe("OWNER");
29+
expect(normalizeSlackAccountRole("AGENT")).toBe("AGENT");
30+
expect(normalizeSlackAccountRole("TEAM")).toBe("TEAM");
31+
});
32+
33+
it("uppercases mixed-case input", () => {
34+
expect(normalizeSlackAccountRole("owner")).toBe("OWNER");
35+
expect(normalizeSlackAccountRole("Agent")).toBe("AGENT");
36+
expect(normalizeSlackAccountRole(" team ")).toBe("TEAM");
37+
});
38+
39+
it("falls back to AGENT for unknown / non-string values", () => {
40+
expect(normalizeSlackAccountRole(undefined)).toBe("AGENT");
41+
expect(normalizeSlackAccountRole(null)).toBe("AGENT");
42+
expect(normalizeSlackAccountRole("")).toBe("AGENT");
43+
expect(normalizeSlackAccountRole("admin")).toBe("AGENT");
44+
expect(normalizeSlackAccountRole(42)).toBe("AGENT");
45+
expect(normalizeSlackAccountRole({ role: "OWNER" })).toBe("AGENT");
46+
});
47+
});
48+
49+
describe("resolveSlackAccount role wiring", () => {
50+
it("defaults role to AGENT when no role is configured", () => {
51+
const runtime = createRuntime({
52+
botToken: "xoxb-bot",
53+
appToken: "xapp-app",
54+
});
55+
const account = resolveSlackAccount(runtime, "default");
56+
expect(account.role).toBe("AGENT");
57+
});
58+
59+
it("reads role from per-account character.settings.slack.accounts entry", () => {
60+
const runtime = createRuntime({
61+
accounts: {
62+
owner: {
63+
role: "OWNER",
64+
botToken: "xoxb-bot",
65+
appToken: "xapp-app",
66+
userToken: "xoxp-user",
67+
},
68+
},
69+
});
70+
const account = resolveSlackAccount(runtime, "owner");
71+
expect(account.role).toBe("OWNER");
72+
expect(account.userToken).toBe("xoxp-user");
73+
});
74+
75+
it("reads role from SLACK_ACCOUNT_ROLE env for the default account only", () => {
76+
const runtime = createRuntime(
77+
{ botToken: "xoxb-bot", appToken: "xapp-app" },
78+
{ SLACK_ACCOUNT_ROLE: "OWNER" },
79+
);
80+
const account = resolveSlackAccount(runtime, "default");
81+
expect(account.role).toBe("OWNER");
82+
});
83+
84+
it("does not apply env SLACK_ACCOUNT_ROLE to non-default accounts", () => {
85+
const runtime = createRuntime(
86+
{
87+
accounts: {
88+
owner: {
89+
botToken: "xoxb-bot",
90+
appToken: "xapp-app",
91+
},
92+
},
93+
},
94+
{ SLACK_ACCOUNT_ROLE: "OWNER" },
95+
);
96+
const account = resolveSlackAccount(runtime, "owner");
97+
// env override only applies to the legacy/default account path, so the
98+
// explicit per-account config (no role set) still falls back to AGENT
99+
expect(account.role).toBe("AGENT");
100+
});
101+
102+
it("config role wins over env role", () => {
103+
const runtime = createRuntime(
104+
{
105+
botToken: "xoxb-bot",
106+
appToken: "xapp-app",
107+
accounts: {
108+
default: { role: "OWNER" },
109+
},
110+
},
111+
{ SLACK_ACCOUNT_ROLE: "AGENT" },
112+
);
113+
const account = resolveSlackAccount(runtime, "default");
114+
expect(account.role).toBe("OWNER");
115+
});
116+
});

plugins/plugin-slack/src/accounts.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { IAgentRuntime } from "@elizaos/core";
1+
import type { ConnectorAccountRole, IAgentRuntime } from "@elizaos/core";
22

33
/**
44
* Default account identifier used when no specific account is configured
@@ -81,6 +81,14 @@ export interface SlackAccountConfig {
8181
name?: string;
8282
/** If false, do not start this Slack account */
8383
enabled?: boolean;
84+
/**
85+
* Account role. AGENT (the default) means outbound API calls are made
86+
* with the bot token (xoxb-) and represent the agent identity. OWNER
87+
* means outbound calls that have user-token coverage (chat:write user
88+
* scope) are made with the xoxp- user token so the agent acts as the
89+
* user who installed the integration.
90+
*/
91+
role?: ConnectorAccountRole;
8492
/** Slack bot token (xoxb-...) */
8593
botToken?: string;
8694
/** Slack app-level token (xapp-...) */
@@ -138,6 +146,12 @@ export interface ResolvedSlackAccount {
138146
accountId: string;
139147
enabled: boolean;
140148
name?: string;
149+
/**
150+
* Role this account represents in OWNER+AGENT terms. Drives outbound
151+
* API client selection in the runtime: AGENT → bot token, OWNER →
152+
* user token for calls covered by the granted user scopes.
153+
*/
154+
role: ConnectorAccountRole;
141155
botToken?: string;
142156
appToken?: string;
143157
signingSecret?: string;
@@ -190,6 +204,22 @@ export function resolveSlackUserToken(raw?: string | null): string | undefined {
190204
return normalizeSlackToken(raw, "xoxp-");
191205
}
192206

207+
/**
208+
* Normalises an inbound role string into a `ConnectorAccountRole`.
209+
* Unknown values fall back to AGENT — the default for legacy single
210+
* bot-token deployments where the agent IS the bot.
211+
*/
212+
export function normalizeSlackAccountRole(
213+
raw: unknown,
214+
): ConnectorAccountRole {
215+
if (typeof raw !== "string") return "AGENT";
216+
const upper = raw.trim().toUpperCase();
217+
if (upper === "OWNER" || upper === "AGENT" || upper === "TEAM") {
218+
return upper;
219+
}
220+
return "AGENT";
221+
}
222+
193223
/**
194224
* Gets the multi-account configuration from runtime settings
195225
*/
@@ -359,10 +389,20 @@ export function resolveSlackAccount(
359389
const configUserToken = resolveSlackUserToken(merged.userToken);
360390
const userToken = configUserToken ?? envUserToken;
361391

392+
// Resolve role. Precedence: per-account config role > env override
393+
// (default account only) > "AGENT". AGENT is the legacy default — the
394+
// agent acts as the bot identity. OWNER routes user-scope-covered
395+
// outbound calls through the xoxp- user token.
396+
const envRole = allowEnv
397+
? (runtime.getSetting("SLACK_ACCOUNT_ROLE") as string | undefined)
398+
: undefined;
399+
const role = normalizeSlackAccountRole(merged.role ?? envRole);
400+
362401
return {
363402
accountId: normalizedAccountId,
364403
enabled,
365404
name: merged.name?.trim() || undefined,
405+
role,
366406
botToken,
367407
appToken,
368408
signingSecret,

plugins/plugin-slack/src/service.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
type World,
2222
} from "@elizaos/core";
2323
import { App, LogLevel } from "@slack/bolt";
24-
import type { WebAPICallResult } from "@slack/web-api";
24+
import { WebClient as SlackWebClient, type WebAPICallResult } from "@slack/web-api";
2525

2626
type WebClient = App["client"];
2727
type AccountScopedTargetInfo = TargetInfo & { accountId?: string };
@@ -317,6 +317,14 @@ type SlackAccountRuntime = {
317317
account: ResolvedSlackAccount;
318318
app: App;
319319
client: WebClient;
320+
/**
321+
* Optional xoxp- user-token client. Present only when the account
322+
* has a `userToken` configured. OWNER-role accounts route outbound
323+
* calls covered by the granted user scopes (currently `chat:write`)
324+
* through this client so the agent acts as the user; AGENT-role
325+
* accounts ignore it and keep using the bot client.
326+
*/
327+
userClient: WebClient | null;
320328
botUserId: string | null;
321329
teamId: string | null;
322330
settings: SlackSettings;
@@ -717,11 +725,20 @@ export class SlackService extends Service implements ISlackService {
717725
: {}),
718726
});
719727

728+
// User-token client (xoxp-) is outbound-only; no socket-mode
729+
// session is needed. Only constructed when a user token is
730+
// configured. Routing decisions in getOutboundClient() consult
731+
// account.role to decide which client receives each call.
732+
const userClient = account.userToken
733+
? (new SlackWebClient(account.userToken) as unknown as WebClient)
734+
: null;
735+
720736
const state: SlackAccountRuntime = {
721737
accountId,
722738
account,
723739
app,
724740
client: app.client,
741+
userClient,
725742
botUserId: null,
726743
teamId: null,
727744
settings: this.loadSettings(account),
@@ -897,6 +914,25 @@ export class SlackService extends Service implements ISlackService {
897914
return null;
898915
}
899916

917+
/**
918+
* Returns the client that outbound user-action calls (currently
919+
* chat.postMessage) should use for the given account. OWNER-role
920+
* accounts with a configured xoxp- user token route through it so
921+
* the agent posts as the user; everything else stays on the bot
922+
* client. Falls back to `getClientForAccount` when no per-account
923+
* state has been initialised yet.
924+
*/
925+
private getOutboundClient(accountId?: string | null): WebClient | null {
926+
const state = this.getAccountState(accountId);
927+
if (!state) {
928+
return this.getClientForAccount(accountId);
929+
}
930+
if (state.account.role === "OWNER" && state.userClient) {
931+
return state.userClient;
932+
}
933+
return state.client;
934+
}
935+
900936
private getSettingsForAccount(accountId?: string | null): SlackSettings {
901937
return this.getAccountState(accountId)?.settings ?? this.settings;
902938
}
@@ -2855,7 +2891,7 @@ export class SlackService extends Service implements ISlackService {
28552891
options?: SlackMessageSendOptions,
28562892
accountId?: string | null,
28572893
): Promise<{ ts: string; channelId: string }> {
2858-
const client = this.getClientForAccount(accountId);
2894+
const client = this.getOutboundClient(accountId);
28592895
if (!client) {
28602896
throw new Error("Slack client not initialized");
28612897
}

0 commit comments

Comments
 (0)