你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

开始使用针对 Teams 语音应用的 Azure 通信服务 UI 库调用

Home page of Calling Widget sample app

此项目旨在指导开发人员使用 Azure 通信 UI 库从 Azure 通信服务呼叫 Web SDK 向 Teams 呼叫队列和自动助理发起呼叫。

根据要求,可能需要为客户提供简单的方式来联系你,但无需任何复杂的设置。

调用 Teams 呼叫队列和自动助理是简单而有效的概念,有助于与客户支持、财务顾问和其他面向客户的团队进行即时互动。 本教程的目标是帮助你在客户单击 Web 上的按钮时启动与客户的交互。

如果想尝试它,可以从 GitHub 下载代码。

遵循本教程将:

  • 允许根据客户方案控制客户的音频和视频体验
  • 教你如何构建一个小组件,以便使用 UI 库在 webapp 上启动调用。

先决条件

设置项目

只有在创建新应用程序时才使用此步骤。

要设置 react 应用,使用 create-react-app 命令行工具。 此工具创建了由 React 支持的易于运行的 TypeScript 应用程序。 此命令使用 TypeScript 创建 react 应用程序。

# Create an Azure Communication Services App powered by React.
npx create-react-app ui-library-calling-widget-app --template typescript

# Change to the directory of the newly created App.
cd ui-library-calling-widget-app

取得依赖项

然后,你需要更新 package.json 中的依赖项数组,以包括来自 Azure 通信服务的一些包,从而实现我们将要构建的小组件体验:

"@azure/communication-calling": "1.22.1",
"@azure/communication-chat": "1.4.0",
"@azure/communication-react": "1.13.0",
"@azure/communication-calling-effects": "1.0.1",
"@azure/communication-common": "2.3.0",
"@fluentui/react-icons": "~2.0.203",
"@fluentui/react": "~8.98.3",

将这些包添加到 package.json 后,你就可以开始处理新项目了。 在本教程中,我们将修改 src 目录中的文件。

初始应用设置

首先,我们将提供的 App.tsx 内容替换为主页,该主页将:

  • 存储创建 CallAdapter 以增强呼叫体验所需的所有 Azure 通信信息
  • 显示向最终用户公开的小组件。

App.tsx 文件应如下所示:

src/App.tsx

import "./App.css";
import {
  CommunicationIdentifier,
  MicrosoftTeamsAppIdentifier,
} from "@azure/communication-common";
import {
  Spinner,
  Stack,
  initializeIcons,
  registerIcons,
  Text,
} from "@fluentui/react";
import { CallAdd20Regular, Dismiss20Regular } from "@fluentui/react-icons";
import logo from "./logo.svg";

import { CallingWidgetComponent } from "./components/CallingWidgetComponent";

registerIcons({
  icons: { dismiss: <Dismiss20Regular />, callAdd: <CallAdd20Regular /> },
});
initializeIcons();
function App() {
  /**
   * Token for local user.
   */
  const token = "<Enter your ACS Token here>";

  /**
   * User identifier for local user.
   */
  const userId: CommunicationIdentifier = {
    communicationUserId: "Enter your ACS Id here",
  };

  /**
   * Enter your Teams voice app identifier from the Teams admin center here
   */
  const teamsAppIdentifier: MicrosoftTeamsAppIdentifier = {
    teamsAppId: "<Enter your Teams Voice app id here>",
    cloud: "public",
  };

  const widgetParams = {
    userId,
    token,
    teamsAppIdentifier,
  };

  if (!token || !userId || !teamsAppIdentifier) {
    return (
      <Stack verticalAlign="center" style={{ height: "100%", width: "100%" }}>
        <Spinner
          label={"Getting user credentials from server"}
          ariaLive="assertive"
          labelPosition="top"
        />
        ;
      </Stack>
    );
  }

  return (
    <Stack
      style={{ height: "100%", width: "100%", padding: "3rem" }}
      tokens={{ childrenGap: "1.5rem" }}
    >
      <Stack tokens={{ childrenGap: "1rem" }} style={{ margin: "auto" }}>
        <Stack
          style={{ padding: "3rem" }}
          horizontal
          tokens={{ childrenGap: "2rem" }}
        >
          <Text style={{ marginTop: "auto" }} variant="xLarge">
            Welcome to a Calling Widget sample
          </Text>
          <img
            style={{ width: "7rem", height: "auto" }}
            src={logo}
            alt="logo"
          />
        </Stack>

        <Text>
          Welcome to a Calling Widget sample for the Azure Communication
          Services UI Library. Sample has the ability to connect you through
          Teams voice apps to a agent to help you.
        </Text>
        <Text>
          As a user all you need to do is click the widget below, enter your
          display name for the call - this will act as your caller id, and
          action the <b>start call</b> button.
        </Text>
      </Stack>
      <Stack
        horizontal
        tokens={{ childrenGap: "1.5rem" }}
        style={{ overflow: "hidden", margin: "auto" }}
      >
        <CallingWidgetComponent
          widgetAdapterArgs={widgetParams}
          onRenderLogo={() => {
            return (
              <img
                style={{ height: "4rem", width: "4rem", margin: "auto" }}
                src={logo}
                alt="logo"
              />
            );
          }}
        />
      </Stack>
    </Stack>
  );
}

export default App;

在这个片段中,我们注册了两个新的图标 <Dismiss20Regular/><CallAdd20Regular>。 这些新图标在下一部分创建的小组件组件中使用。

创建小组件

现在我们需要制作一个可以以三种不同模式显示的小组件:

  • 正在等待:此小组件状态是组件在进行调用之前和之后的状态
  • 设置:此状态是指小组件从用户请求信息(例如姓名)时的状态。
  • 在调用中:此处的小组件被替换为 UI 库通话复合。 此小组件模式是用户呼叫 Voice 应用或与代理交谈时的模式。

让我们创建一个名为 src/components 的文件夹。 在此文件夹中,创建一个名为 CallingWidgetComponent.tsx 的新文件。 该文件应如以下代码片段所示:

CallingWidgetComponent.tsx

import {
  IconButton,
  PrimaryButton,
  Stack,
  TextField,
  useTheme,
  Checkbox,
  Icon,
  Spinner,
} from "@fluentui/react";
import React, { useEffect, useRef, useState } from "react";
import {
  callingWidgetSetupContainerStyles,
  checkboxStyles,
  startCallButtonStyles,
  callingWidgetContainerStyles,
  callIconStyles,
  logoContainerStyles,
  collapseButtonStyles,
} from "../styles/CallingWidgetComponent.styles";

import {
  AzureCommunicationTokenCredential,
  CommunicationUserIdentifier,
  MicrosoftTeamsAppIdentifier,
} from "@azure/communication-common";
import {
  CallAdapter,
  CallAdapterState,
  CallComposite,
  CommonCallAdapterOptions,
  StartCallIdentifier,
  createAzureCommunicationCallAdapter,
} from "@azure/communication-react";
// lets add to our react imports as well
import { useMemo } from "react";

import { callingWidgetInCallContainerStyles } from "../styles/CallingWidgetComponent.styles";

/**
 * Properties needed for our widget to start a call.
 */
export type WidgetAdapterArgs = {
  token: string;
  userId: CommunicationUserIdentifier;
  teamsAppIdentifier: MicrosoftTeamsAppIdentifier;
};

export interface CallingWidgetComponentProps {
  /**
   *  arguments for creating an AzureCommunicationCallAdapter for your Calling experience
   */
  widgetAdapterArgs: WidgetAdapterArgs;
  /**
   * Custom render function for displaying logo.
   * @returns
   */
  onRenderLogo?: () => JSX.Element;
}

/**
 * Widget for Calling Widget
 * @param props
 */
export const CallingWidgetComponent = (
  props: CallingWidgetComponentProps
): JSX.Element => {
  const { onRenderLogo, widgetAdapterArgs } = props;

  const [widgetState, setWidgetState] = useState<"new" | "setup" | "inCall">(
    "new"
  );
  const [displayName, setDisplayName] = useState<string>();
  const [consentToData, setConsentToData] = useState<boolean>(false);
  const [useLocalVideo, setUseLocalVideo] = useState<boolean>(false);
  const [adapter, setAdapter] = useState<CallAdapter>();

  const callIdRef = useRef<string>();

  const theme = useTheme();

  // add this before the React template
  const credential = useMemo(() => {
    try {
      return new AzureCommunicationTokenCredential(widgetAdapterArgs.token);
    } catch {
      console.error("Failed to construct token credential");
      return undefined;
    }
  }, [widgetAdapterArgs.token]);

  const adapterOptions: CommonCallAdapterOptions = useMemo(
    () => ({
      callingSounds: {
        callEnded: { url: "/sounds/callEnded.mp3" },
        callRinging: { url: "/sounds/callRinging.mp3" },
        callBusy: { url: "/sounds/callBusy.mp3" },
      },
    }),
    []
  );

  const callAdapterArgs = useMemo(() => {
    return {
      userId: widgetAdapterArgs.userId,
      credential: credential,
      targetCallees: [
        widgetAdapterArgs.teamsAppIdentifier,
      ] as StartCallIdentifier[],
      displayName: displayName,
      options: adapterOptions,
    };
  }, [
    widgetAdapterArgs.userId,
    widgetAdapterArgs.teamsAppIdentifier.teamsAppId,
    credential,
    displayName,
  ]);

  useEffect(() => {
    if (adapter) {
      adapter.on("callEnded", () => {
        /**
         * We only want to reset the widget state if the call that ended is the same as the current call.
         */
        if (
          adapter.getState().acceptedTransferCallState &&
          adapter.getState().acceptedTransferCallState?.id !== callIdRef.current
        ) {
          return;
        }
        setDisplayName(undefined);
        setWidgetState("new");
        setConsentToData(false);
        setAdapter(undefined);
        adapter.dispose();
      });

      adapter.on("transferAccepted", (e) => {
        console.log("transferAccepted", e);
      });

      adapter.onStateChange((state: CallAdapterState) => {
        if (state?.call?.id && callIdRef.current !== state?.call?.id) {
          callIdRef.current = state?.call?.id;
          console.log(`Call Id: ${callIdRef.current}`);
        }
      });
    }
  }, [adapter]);

  /** widget template for when widget is open, put any fields here for user information desired */
  if (widgetState === "setup") {
    return (
      <Stack
        styles={callingWidgetSetupContainerStyles(theme)}
        tokens={{ childrenGap: "1rem" }}
      >
        <IconButton
          styles={collapseButtonStyles}
          iconProps={{ iconName: "Dismiss" }}
          onClick={() => {
            setDisplayName(undefined);
            setConsentToData(false);
            setUseLocalVideo(false);
            setWidgetState("new");
          }}
        />
        <Stack tokens={{ childrenGap: "1rem" }} styles={logoContainerStyles}>
          <Stack style={{ transform: "scale(1.8)" }}>
            {onRenderLogo && onRenderLogo()}
          </Stack>
        </Stack>
        <TextField
          label={"Name"}
          required={true}
          placeholder={"Enter your name"}
          onChange={(_, newValue) => {
            setDisplayName(newValue);
          }}
        />
        <Checkbox
          styles={checkboxStyles(theme)}
          label={
            "Use video - Checking this box will enable camera controls and screen sharing"
          }
          onChange={(_, checked?: boolean | undefined) => {
            setUseLocalVideo(!!checked);
            setUseLocalVideo(true);
          }}
        ></Checkbox>
        <Checkbox
          required={true}
          styles={checkboxStyles(theme)}
          disabled={displayName === undefined}
          label={
            "By checking this box, you are consenting that we will collect data from the call for customer support reasons"
          }
          onChange={async (_, checked?: boolean | undefined) => {
            setConsentToData(!!checked);
            if (callAdapterArgs && callAdapterArgs.credential) {
              setAdapter(
                await createAzureCommunicationCallAdapter({
                  displayName: displayName ?? "",
                  userId: callAdapterArgs.userId,
                  credential: callAdapterArgs.credential,
                  targetCallees: callAdapterArgs.targetCallees,
                  options: callAdapterArgs.options,
                })
              );
            }
          }}
        ></Checkbox>
        <PrimaryButton
          styles={startCallButtonStyles(theme)}
          onClick={() => {
            if (displayName && consentToData && adapter) {
              setWidgetState("inCall");
              adapter?.startCall(callAdapterArgs.targetCallees, {
                audioOptions: { muted: false },
              });
            }
          }}
        >
          {!consentToData && `Enter your name`}
          {consentToData && !adapter && (
            <Spinner ariaLive="assertive" labelPosition="top" />
          )}
          {consentToData && adapter && `StartCall`}
        </PrimaryButton>
      </Stack>
    );
  }

  if (widgetState === "inCall" && adapter) {
    return (
      <Stack styles={callingWidgetInCallContainerStyles(theme)}>
        <CallComposite
          adapter={adapter}
          options={{
            callControls: {
              cameraButton: useLocalVideo,
              screenShareButton: useLocalVideo,
              moreButton: false,
              peopleButton: false,
              displayType: "compact",
            },
            localVideoTile: !useLocalVideo ? false : { position: "floating" },
          }}
        />
      </Stack>
    );
  }

  return (
    <Stack
      horizontalAlign="center"
      verticalAlign="center"
      styles={callingWidgetContainerStyles(theme)}
      onClick={() => {
        setWidgetState("setup");
      }}
    >
      <Stack
        horizontalAlign="center"
        verticalAlign="center"
        style={{
          height: "4rem",
          width: "4rem",
          borderRadius: "50%",
          background: theme.palette.themePrimary,
        }}
      >
        <Icon iconName="callAdd" styles={callIconStyles(theme)} />
      </Stack>
    </Stack>
  );
};

设置小组件样式

我们需要编写一些样式来确保小组件看起来合适并且可以容纳我们的通话复合。 如果复制添加到文件 CallingWidgetComponent.tsx 的代码片段,则应在小组件中使用这些样式。

让我们创建一个名为 src/styles 的新文件夹,并在此文件夹中创建一个名为 CallingWidgetComponent.styles.ts 的文件。 该文件应如以下代码片段所示:

import {
  IButtonStyles,
  ICheckboxStyles,
  IIconStyles,
  IStackStyles,
  Theme,
} from "@fluentui/react";

export const checkboxStyles = (theme: Theme): ICheckboxStyles => {
  return {
    label: {
      color: theme.palette.neutralPrimary,
    },
  };
};

export const callingWidgetContainerStyles = (theme: Theme): IStackStyles => {
  return {
    root: {
      width: "5rem",
      height: "5rem",
      padding: "0.5rem",
      boxShadow: theme.effects.elevation16,
      borderRadius: "50%",
      bottom: "1rem",
      right: "1rem",
      position: "absolute",
      overflow: "hidden",
      cursor: "pointer",
      ":hover": {
        boxShadow: theme.effects.elevation64,
      },
    },
  };
};

export const callingWidgetSetupContainerStyles = (
  theme: Theme
): IStackStyles => {
  return {
    root: {
      width: "18rem",
      minHeight: "20rem",
      maxHeight: "25rem",
      padding: "0.5rem",
      boxShadow: theme.effects.elevation16,
      borderRadius: theme.effects.roundedCorner6,
      bottom: 0,
      right: "1rem",
      position: "absolute",
      overflow: "hidden",
      cursor: "pointer",
      background: theme.palette.white,
    },
  };
};

export const callIconStyles = (theme: Theme): IIconStyles => {
  return {
    root: {
      paddingTop: "0.2rem",
      color: theme.palette.white,
      transform: "scale(1.6)",
    },
  };
};

export const startCallButtonStyles = (theme: Theme): IButtonStyles => {
  return {
    root: {
      background: theme.palette.themePrimary,
      borderRadius: theme.effects.roundedCorner6,
      borderColor: theme.palette.themePrimary,
    },
    textContainer: {
      color: theme.palette.white,
    },
  };
};

export const logoContainerStyles: IStackStyles = {
  root: {
    margin: "auto",
    padding: "0.2rem",
    height: "5rem",
    width: "10rem",
    zIndex: 0,
  },
};

export const collapseButtonStyles: IButtonStyles = {
  root: {
    position: "absolute",
    top: "0.2rem",
    right: "0.2rem",
    zIndex: 1,
  },
};

export const callingWidgetInCallContainerStyles = (
  theme: Theme
): IStackStyles => {
  return {
    root: {
      width: "35rem",
      height: "25rem",
      padding: "0.5rem",
      boxShadow: theme.effects.elevation16,
      borderRadius: theme.effects.roundedCorner6,
      bottom: 0,
      right: "1rem",
      position: "absolute",
      overflow: "hidden",
      cursor: "pointer",
      background: theme.semanticColors.bodyBackground,
    },
  };
};

运行应用

我们终于可以运行应用程序来拨打电话了! 运行以下命令来安装依赖项并运行应用。

# Install the newe dependencies
npm install

# run the React app
npm run start

应用程序运行后,可以在浏览器的 http://localhost:3000 上看到它。 应会看到以下初始屏幕:

Screenshot of calling widget sample app home page widget closed.

然后,在操作小组件按钮时,应会看到一个小菜单:

Screenshot of calling widget sample app home page widget open.

填写姓名后,单击“开始通话”,通话就会开始。 开始通话后小组件应如下所示:

Screenshot of click to call sample app home page with calling experience embedded in widget.

后续步骤

有关 Teams 语音应用程序的详细信息,请查看有关 Teams 自动助理和 Teams 呼叫队列的文档。

快速入门:在 Teams 呼叫队列中加入通话应用

快速入门:在 Teams 自动助理中加入呼叫应用