refactor: update Kefu widget configuration to use dynamic user token retrieval
- Changed `userToken` property to `getUserToken` function in KefuWidgetHostConfig for dynamic token fetching. - Introduced KefuWidgetRuntimeConfig to manage runtime-specific configurations. - Updated readKefuWidgetConfig and setKefuWidgetConfig functions to accommodate new configuration structure. - Enhanced cs-ai-agent-sdk.js to handle user token resolution and frame configuration. - Added tests for dynamic user token retrieval in cs-ai-agent-sdk.test.mjs. - Removed deprecated cs-agent-widget.js file as part of the refactor.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
setKefuWidgetConfig,
|
||||
type KefuWidgetHostConfig,
|
||||
type KefuWidgetRuntimeConfig,
|
||||
} from "@/lib/kefu-widget-config"
|
||||
|
||||
type HostBridgeOptions = {
|
||||
@@ -32,7 +32,7 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
|
||||
const data = event.data as
|
||||
| {
|
||||
type?: string
|
||||
payload?: KefuWidgetHostConfig | { isMaximized?: boolean }
|
||||
payload?: KefuWidgetRuntimeConfig | { isMaximized?: boolean }
|
||||
}
|
||||
| undefined
|
||||
if (!data?.type) {
|
||||
@@ -40,7 +40,7 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
|
||||
}
|
||||
|
||||
if (data.type === INIT_MESSAGE_TYPE && data.payload) {
|
||||
setKefuWidgetConfig(data.payload as KefuWidgetHostConfig)
|
||||
setKefuWidgetConfig(data.payload as KefuWidgetRuntimeConfig)
|
||||
options.onInit?.()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ export type KefuWidgetHostConfig = {
|
||||
externalId?: string
|
||||
/** 访客展示名,仅用于首次换取客服会话 token */
|
||||
externalName?: string
|
||||
/** 业务系统签发的前台用户 JWT,仅用于首次换取客服会话 token */
|
||||
userToken?: string
|
||||
/** 打开客服前按需获取业务系统签发的前台用户 JWT */
|
||||
getUserToken?: () => string | Promise<string>
|
||||
title?: string
|
||||
subtitle?: string
|
||||
position?: "left" | "right"
|
||||
@@ -15,15 +15,20 @@ export type KefuWidgetHostConfig = {
|
||||
width?: string
|
||||
}
|
||||
|
||||
export type KefuWidgetRuntimeConfig = Omit<KefuWidgetHostConfig, "getUserToken"> & {
|
||||
/** 仅用于 /kefu/chat 运行时换取客服会话 token,不属于 CSAgentConfig 接入参数 */
|
||||
userToken?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CSAgentConfig?: KefuWidgetHostConfig
|
||||
__CS_AGENT_WIDGET_CONFIG__?: KefuWidgetHostConfig
|
||||
__CS_AGENT_WIDGET_CONFIG__?: KefuWidgetRuntimeConfig
|
||||
__CS_AGENT_WIDGET_STATE__?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function readKefuWidgetConfig(): KefuWidgetHostConfig {
|
||||
export function readKefuWidgetConfig(): KefuWidgetRuntimeConfig {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
channelId: "",
|
||||
@@ -33,7 +38,7 @@ export function readKefuWidgetConfig(): KefuWidgetHostConfig {
|
||||
}
|
||||
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
const fallback: KefuWidgetHostConfig = {
|
||||
const fallback: KefuWidgetRuntimeConfig = {
|
||||
channelId:
|
||||
query.get("channelId") ??
|
||||
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ??
|
||||
@@ -56,10 +61,20 @@ export function readKefuWidgetConfig(): KefuWidgetHostConfig {
|
||||
width: query.get("width") ?? undefined,
|
||||
}
|
||||
|
||||
return window.__CS_AGENT_WIDGET_CONFIG__ ?? window.CSAgentConfig ?? fallback
|
||||
if (window.__CS_AGENT_WIDGET_CONFIG__) {
|
||||
return window.__CS_AGENT_WIDGET_CONFIG__
|
||||
}
|
||||
if (window.CSAgentConfig) {
|
||||
const { getUserToken: _getUserToken, ...hostConfig } = window.CSAgentConfig
|
||||
return {
|
||||
...fallback,
|
||||
...hostConfig,
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function setKefuWidgetConfig(config: KefuWidgetHostConfig) {
|
||||
export function setKefuWidgetConfig(config: KefuWidgetRuntimeConfig) {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
frameHideTimer: null,
|
||||
frameDestroyTimer: null,
|
||||
config: null,
|
||||
frameConfig: null,
|
||||
frameUrl: null,
|
||||
animationDuration: 260,
|
||||
};
|
||||
@@ -49,8 +50,8 @@
|
||||
if (merged.externalId) {
|
||||
merged.externalId = String(merged.externalId);
|
||||
}
|
||||
if (merged.userToken) {
|
||||
merged.userToken = String(merged.userToken);
|
||||
if (typeof merged.getUserToken !== "function") {
|
||||
delete merged.getUserToken;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -63,7 +64,7 @@
|
||||
return String(config.widgetBaseUrl || config.baseUrl || window.location.origin).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function createFrameUrl(config) {
|
||||
function createFrameUrl(config, userToken) {
|
||||
var widgetBaseUrl = resolveWidgetBaseUrl(config);
|
||||
var frameUrl = new URL(widgetBaseUrl + "/kefu/chat/");
|
||||
frameUrl.searchParams.set("channelId", config.channelId);
|
||||
@@ -71,10 +72,49 @@
|
||||
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl);
|
||||
if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId);
|
||||
if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName);
|
||||
if (config.userToken) frameUrl.searchParams.set("userToken", config.userToken);
|
||||
if (userToken) frameUrl.searchParams.set("userToken", userToken);
|
||||
return frameUrl;
|
||||
}
|
||||
|
||||
function createFrameConfig(config, userToken) {
|
||||
var payload = {};
|
||||
var key;
|
||||
for (key in config) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(config, key) &&
|
||||
key !== "getUserToken"
|
||||
) {
|
||||
payload[key] = config[key];
|
||||
}
|
||||
}
|
||||
if (userToken) {
|
||||
payload.userToken = userToken;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function resolveUserToken() {
|
||||
var config = state.config || {};
|
||||
if (typeof config.getUserToken !== "function") {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
try {
|
||||
return Promise.resolve(config.getUserToken()).then(function (token) {
|
||||
return String(token || "").trim();
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareFrameUrl() {
|
||||
return resolveUserToken().then(function (userToken) {
|
||||
state.frameUrl = createFrameUrl(state.config, userToken);
|
||||
state.frameConfig = createFrameConfig(state.config, userToken);
|
||||
return state.frameUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeWidgetConfig(config, remoteConfig) {
|
||||
if (!remoteConfig) {
|
||||
return config;
|
||||
@@ -199,7 +239,7 @@
|
||||
state.initSent = true;
|
||||
postToFrame({
|
||||
type: "cs-agent:init",
|
||||
payload: state.config,
|
||||
payload: state.frameConfig || createFrameConfig(state.config, ""),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -388,11 +428,13 @@
|
||||
button.appendChild(text);
|
||||
|
||||
button.addEventListener("click", function () {
|
||||
if (!state.frame) {
|
||||
createFrame();
|
||||
if (state.isOpen) {
|
||||
state.isOpen = false;
|
||||
syncFrameVisibility();
|
||||
return;
|
||||
}
|
||||
state.isOpen = !state.isOpen;
|
||||
syncFrameVisibility();
|
||||
|
||||
openWidget();
|
||||
});
|
||||
|
||||
document.body.appendChild(button);
|
||||
@@ -416,7 +458,6 @@
|
||||
fetchWidgetConfig(state.config).then(function (nextConfig) {
|
||||
state.configLoading = false;
|
||||
state.config = normalizeConfig(nextConfig);
|
||||
state.frameUrl = createFrameUrl(state.config);
|
||||
if (state.button && state.button.parentNode) {
|
||||
state.button.parentNode.removeChild(state.button);
|
||||
state.button = null;
|
||||
@@ -441,25 +482,48 @@
|
||||
state.isOpen = false;
|
||||
state.isMaximized = false;
|
||||
state.configLoading = false;
|
||||
state.frameConfig = null;
|
||||
state.frameUrl = null;
|
||||
}
|
||||
|
||||
function openWidget() {
|
||||
return prepareFrameUrl()
|
||||
.then(function () {
|
||||
if (!state.frame) {
|
||||
createFrame();
|
||||
}
|
||||
if (!state.frame) {
|
||||
return;
|
||||
}
|
||||
state.isOpen = true;
|
||||
syncFrameVisibility();
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error("[cs-agent-widget] open failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
window.CSAgentWidget = {
|
||||
mount: mount,
|
||||
destroy: destroy,
|
||||
open: function () {
|
||||
if (!state.frame) {
|
||||
createFrame();
|
||||
}
|
||||
if (!state.frame) {
|
||||
return;
|
||||
}
|
||||
state.isOpen = true;
|
||||
syncFrameVisibility();
|
||||
return openWidget();
|
||||
},
|
||||
close: function () {
|
||||
state.isOpen = false;
|
||||
syncFrameVisibility();
|
||||
},
|
||||
getChatUrl: function () {
|
||||
if (!state.config) {
|
||||
mount(window.CSAgentConfig || {});
|
||||
}
|
||||
if (!state.config || !state.config.channelId) {
|
||||
return Promise.reject(new Error("channelId is required"));
|
||||
}
|
||||
return prepareFrameUrl().then(function (frameUrl) {
|
||||
return frameUrl.toString();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (!state.listenerBound) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
|
||||
function createElement(tagName) {
|
||||
return {
|
||||
tagName,
|
||||
children: [],
|
||||
dataset: {},
|
||||
style: {},
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
parentNode: null,
|
||||
contentWindow: {},
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value)
|
||||
},
|
||||
appendChild(child) {
|
||||
child.parentNode = this
|
||||
this.children.push(child)
|
||||
return child
|
||||
},
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((item) => item !== child)
|
||||
child.parentNode = null
|
||||
return child
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
this.listeners[type] = handler
|
||||
},
|
||||
click() {
|
||||
this.listeners.click?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSdk(config) {
|
||||
const source = await readFile(new URL("./cs-ai-agent-sdk.js", import.meta.url), "utf8")
|
||||
const body = createElement("body")
|
||||
const sandbox = {
|
||||
URL,
|
||||
console,
|
||||
fetch: async () => ({
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
title: "在线客服",
|
||||
themeColor: "#2563eb",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
document: {
|
||||
body,
|
||||
currentScript: {
|
||||
src: "https://chat.example/sdk/cs-ai-agent-sdk.min.js",
|
||||
},
|
||||
createElement,
|
||||
createElementNS: (_namespace, tagName) => createElement(tagName),
|
||||
},
|
||||
window: {
|
||||
CSAgentConfig: config,
|
||||
location: {
|
||||
origin: "https://host.example",
|
||||
},
|
||||
addEventListener() {},
|
||||
clearTimeout() {},
|
||||
setTimeout(handler) {
|
||||
handler()
|
||||
return 1
|
||||
},
|
||||
},
|
||||
}
|
||||
sandbox.window.window = sandbox.window
|
||||
sandbox.window.document = sandbox.document
|
||||
sandbox.window.fetch = sandbox.fetch
|
||||
sandbox.window.console = console
|
||||
sandbox.window.URL = URL
|
||||
sandbox.window.setTimeout = sandbox.window.setTimeout
|
||||
sandbox.window.clearTimeout = sandbox.window.clearTimeout
|
||||
|
||||
vm.runInNewContext(source, sandbox)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
return sandbox
|
||||
}
|
||||
|
||||
async function flushPromises(count = 5) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
test("getChatUrl resolves a fresh userToken for each call", async () => {
|
||||
let calls = 0
|
||||
const sandbox = await loadSdk({
|
||||
channelId: "ch_1",
|
||||
baseUrl: "https://api.example",
|
||||
getUserToken: async () => `token_${++calls}`,
|
||||
})
|
||||
|
||||
assert.equal(typeof sandbox.window.CSAgentWidget.getChatUrl, "function")
|
||||
|
||||
const first = await sandbox.window.CSAgentWidget.getChatUrl()
|
||||
const second = await sandbox.window.CSAgentWidget.getChatUrl()
|
||||
|
||||
assert.equal(new URL(first).searchParams.get("userToken"), "token_1")
|
||||
assert.equal(new URL(second).searchParams.get("userToken"), "token_2")
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test("launcher click creates chat iframe with a freshly resolved userToken", async () => {
|
||||
const sandbox = await loadSdk({
|
||||
channelId: "ch_1",
|
||||
baseUrl: "https://api.example",
|
||||
getUserToken: async () => "click_token",
|
||||
})
|
||||
await flushPromises()
|
||||
const launcher = sandbox.document.body.children.find(
|
||||
(child) => child.dataset.csAgentWidget === "launcher"
|
||||
)
|
||||
|
||||
assert.ok(launcher)
|
||||
|
||||
launcher.click()
|
||||
await flushPromises()
|
||||
|
||||
const frame = sandbox.document.body.children.find(
|
||||
(child) => child.dataset.csAgentWidget === "frame"
|
||||
)
|
||||
|
||||
assert.ok(frame)
|
||||
assert.equal(new URL(frame.src).searchParams.get("userToken"), "click_token")
|
||||
})
|
||||
Reference in New Issue
Block a user