Teams会议侧边栏应用开发-会议转写

ops/2024/9/24 7:18:26/

Teams应用开发,主要是权限比较麻烦,大量阅读和实践,摸索了几周,才搞明白。现将经验总结如下:

一、目标:开发一个Teams会议的侧边栏应用,实现会议的实时转写

二、前提:

1)Teams 365基础版本以上账号Developer Portal,主要是可以登录开发者门户,建议开放管理员权限,以便可以上传开发好的APP(实际上仅仅是个mainfest.json);

2)Teams 基础版本以上账号,可以登录Teams,添加App;

3)Azure 账号,建议开放管理员权限,以方便授予同意权限。

以上是基本要求,否则无法进行后续的工作。

三、需求分解:

1)侧边栏->关键配置(configurableTabs)

2)实时转写->转写->会议组织者ID->会议ID->用户ID

四、涉及的权限:

1)用户token=委托权限(Delegated),如:转录,需要有会议组织者ID的用户权限;

2)应用token=应用权限(Application),如:获取转录列表。

以上两类权限,可以通过在Azure注册应用获得,如:

五、辅助工具:

1)Graph Explorer | Try Microsoft Graph APIs - Microsoft Graph可以帮助你调试API,判断是否有权限,及需要什么权限,如:User.Read, OnlineMeetingTranscript.Read.All 等。

2)jwt.ms: Welcome!帮助你判断获得的Token是哪类Token, typ=user or app。

基本要求交代完成后,下面说说具体的,正式开始。

六、注册Azure 应用

1)先找到入口-应用注册

2)记录注册应用ID和租户ID(获取user token或 app token都要用到)

3)增加一个密钥并记录,后面就看不到了。

4)授予权限,非常重要,否则无法调用对应到接口。

5)需要设置一个回调地址(user token需要)

6)隐式获取设置(可以一步直接获取user token,存储在回调页面/auth的heders URL hash片段)

正常应该是先获取code,然后拿code换token。

以上关于注册应用的设置全部完毕。

七、应用开发

1)开发工具使用VS Code,下载Teams Tookit插件,创建一个Tab应用,使用JS语言,应用名称随意,如:MeetingRTT。

2)mainfest.json,这个很关键。注意3个地方:id(注册应用ID)、configurableTabs(侧边栏配置)和validDomains(合法域名)。

{"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.16/MicrosoftTeams.schema.json","manifestVersion": "1.16","version": "1.0.0","id": "ba82be1b-xxx","packageName": "com.helport.transcription","developer": {"name": "Helport","websiteUrl": "https://your-website.com","privacyUrl": "https://your-website.com/privacy","termsOfUseUrl": "https://your-website.com/terms"},"name": {"short": "Meeting Transcription","full": "Real-time Meeting Transcription"},"description": {"short": "Real-time meeting transcription","full": "This app provides real-time meeting transcription in Teams meetings."},"icons": {"outline": "outline.png","color": "color.png"},"accentColor": "#FFFFFF","configurableTabs": [{"configurationUrl": "https://xxx.ngrok-free.app/config","canUpdateConfiguration": true,"scopes": ["team","groupchat"],"context": ["meetingSidePanel","meetingStage"]}],"permissions": ["identity","messageTeamMembers"],"validDomains": ["xxx.ngrok-free.app"]
}

3) 侧边栏安装配置:一个html页面config.html

<!DOCTYPE html>
<html>
<head><title>Configure Transcription</title><script src="https://statics.teams.cdn.office.net/sdk/v1.11.0/js/MicrosoftTeams.min.js"></script>
</head>
<body><button id="save">Save</button><script>microsoftTeams.initialize();document.getElementById('save').addEventListener('click', () => {microsoftTeams.settings.setSettings({entityId: "transcriptionPanel",contentUrl: "https://xxx.ngrok-free.app/meetingTab",suggestedDisplayName: "Helport"});microsoftTeams.settings.setValidityState(true);microsoftTeams.settings.registerOnSaveHandler((saveEvent) => {saveEvent.notifySuccess();});});</script>
</body>
</html>

4) 本地服务端点实现,主要是为了解决跨域访问问题,需要https://,可以使用ngrok弄个免费的。

本地服务端点,主要实现有:

/confg,上面的侧边栏安装配置页面;

/meetingTab,侧边栏主页面,完成转写

/auth,完成user token的获取,返回一个页面获取access_token(user_token),存储在redis中;

/store_user_token,存储access_token(user_tokne)到redis中;

/get_user_token,从redis获取acces_token(user_token),页面获取会议信息需要;

/getTranscripts,获取转录列表,需要使用app token;

/getTranscriptContent,获取转写,需要使用user_token。

import restify from "restify";
import send from "send";
import fs from "fs";
import fetch from "node-fetch";
import path from 'path';
import { fileURLToPath } from 'url';
import { storeToken, getToken } from './redisClient.js';const __filename = fileURLToPath(import.meta.url);
console.log('__filename: ', __filename);const __dirname = path.dirname(__filename);
console.log('__dirname: ', __dirname);// Create HTTP server.
const server = restify.createServer({key: process.env.SSL_KEY_FILE ? fs.readFileSync(process.env.SSL_KEY_FILE) : undefined,certificate: process.env.SSL_CRT_FILE ? fs.readFileSync(process.env.SSL_CRT_FILE) : undefined,formatters: {"text/html": function (req, res, body) {return body;},},
});server.use(restify.plugins.bodyParser());
server.use(restify.plugins.queryParser());server.get("/static/*",restify.plugins.serveStatic({directory: __dirname,})
);server.listen(process.env.port || process.env.PORT || 3000, function () {console.log(`\n${server.name} listening to ${server.url}`);
});// Adding tabs to our app. This will setup routes to various views
// Setup home page
server.get("/config", (req, res, next) => {send(req, __dirname + "/config/config.html").pipe(res);
});// Setup the static tab
server.get("/meetingTab", (req, res, next) => {send(req, __dirname + "/panel/panel.html").pipe(res);
});//获得用户token
server.get('/auth', (req, res, next) => {res.status(200);res.send(`
<!DOCTYPE html>
<html>
<head><script>// Function to handle the token storageasync function handleToken() {const hash = window.location.hash.substring(1);const hashParams = new URLSearchParams(hash);const access_token = hashParams.get('access_token');console.log('Received hash parameters:', hashParams);if (access_token) {console.log('Access token found:', access_token);localStorage.setItem("access_token", access_token);console.log('Access token stored in localStorage');try {const response = await fetch('https://shortly-adapted-akita.ngrok-free.app/store_user_token', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ "user_token" : access_token })});if (response.ok) {console.log('Token stored successfully');} else {console.error('Failed to store token:', response.statusText);}} catch (error) {console.error('Error storing token:', error);}} else {console.log('No access token found');}window.close();}// Call the function to handle the tokenhandleToken();</script>
</head>
<body></body>
</html>`);next();
});// 存储 user_token
server.post('/store_user_token', async (req, res) => {const user_token = req.body.user_token;if (!user_token) {res.status(400);res.send('user_token are required');}try {// Store user tokenawait storeToken('user_token', user_token);console.log('user_token stored in Redis');} catch (err) {console.error('user_token store Error:', err);}res.status(200);   res.send('Token stored successfully');
});// 获取 user_token
server.get('/get_user_token', async (req, res) => {try {// Store user tokenconst user_token = await getToken('user_token');console.log('user_token get in Redis');res.send({"user_token": user_token});} catch (err) {console.error('user_token get Error:', err);}
});//应用token
let app_token = '';// 定义 /getTranscripts 端点
server.get('/getTranscripts', async (req, res) => {try {let token = "";if (app_token !=''){token = app_token}else{// 构建请求体const requestBody = new URLSearchParams({"grant_type": "client_credentials","client_id": client_id, //注册应用ID"client_secret": client_secret, //主要应用密钥"scope": "https://graph.microsoft.com/.default", //默认范围,即注册应用配置的所有权限}).toString();// 获取app令牌const tokenUrl = `https://login.microsoftonline.com/${tenant_id}/oauth2/v2.0/token`;const tokenResponse = await fetch(tokenUrl, {method: 'POST',headers: {'Content-Type': 'application/x-www-form-urlencoded',},body: requestBody,});if (!tokenResponse.ok) {const errorData = await tokenResponse.json();res.send(500, { error: errorData.error_description });}const tokenData = await tokenResponse.json();app_token = tokenData.access_tokentoken = app_tokenconsole.log("app_token recevied!")}const organizerId = req.query.organizerId;if (!organizerId) {res.send(400, { error: 'Organizer ID is required' });}// 调用 Microsoft Graph APIconst graphUrl = `https://graph.microsoft.com/beta/users/${organizerId}/onlineMeetings/getAllTranscripts(meetingOrganizerUserId='${organizerId}')/delta`;const graphResponse = await fetch(graphUrl, {headers: {Authorization: `Bearer ${token}`,},});if (!graphResponse.ok) {const errorData = await graphResponse.json();res.send(500, { error: errorData.error.message });}const data = await graphResponse.json();// 返回转录文本res.send(200, data);} catch (error) {// 返回错误res.send(500, { error: error.message });}
});// 定义 /getTranscriptContent 端点
server.get('/getTranscriptContent', async (req, res) => {try {const response = await fetch('https://xxx.ngrok-free.app/get_user_token');const token_data = await response.json();const transcriptContentUrl = req.query.transcriptContentUrl;if (!transcriptContentUrl) {res.send(400, { error: 'transcriptContentUrl is required' });}const content_url = `${transcriptContentUrl}?$format=text/vtt`;console.log('content_url:', content_url)// 调用 Microsoft Graph APIconst graphResponse = await fetch(content_url, {headers: {Authorization: `Bearer ${token_data.user_token}`,},});if (!graphResponse.ok) {const errorData = await graphResponse.text();res.send(500, { error: errorData.error.message });}const data = await graphResponse.text();console.log('data:', data)// 返回转录文本res.send(200, data);} catch (error) {// 返回错误res.send(500, { error: error.message });}
});

5)侧边栏页面

获取当前登录用户的信息,获取会议信息,获取会议组织者ID,获取转录列表,获取转写。前提是需要获取user token才可以获取用户信息,这里使用microsoftTeams.authentication.authenticate的url去打开一个授权登录页面,由于当前用户已经登录了,默认不需要去登录,会重定向到/auth,并将user token推送到/auth页面,该/auth服务端点接收到后,存储在redis中,然后关闭页面,之所以使用redis是因为其它存储都无效,从定向后,localStorage、sessionStorage都无法保存,该页面在浏览器中和在teams中完全是两个环境,这个两个环境的数据无法交换,所以只能使用redis在服务端存储。

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Teams User Info</title><script src="https://res.cdn.office.net/teams-js/2.0.0/js/MicrosoftTeams.min.js"></script>
</head>
<body><button id="fetchTranscripts">Fetch Transcripts</button><h2>Meeting Transcripts</h2><div id="transcripts"></div><script>const clientId ='your_client_id';const tenantId = 'your_tentant_id';const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;const redirectUri = 'https://xxx.ngrok-free.app/auth'; // 确保与服务器端一致const scope = 'user.read'; //这个随便,获取到user token后会返回注册应用配置的所有权限const getUserInfo = async (accessToken) => {const graphUrl = 'https://graph.microsoft.com/v1.0/me';const response = await fetch(graphUrl, {headers: {'Authorization': `Bearer ${accessToken}`}});const userInfo = await response.json();return userInfo;};const getMeetingDetails = async (user_token, joinMeetingId) => {const apiUrl = `https://graph.microsoft.com/v1.0/me/onlineMeetings?$filter=joinMeetingIdSettings/joinMeetingId eq '${joinMeetingId}'`;const response = await fetch(apiUrl, {method: 'GET',headers: {'Authorization': `Bearer ${user_token}`,'Content-Type': 'application/json'}});if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`);}const data = await response.json();return data.value[0];};const getTranscripts = async (organizerId) => {const response = await fetch(`https://xxx.ngrok-free.app/getTranscripts?organizerId=${organizerId}`);if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`);}const transcripts = await response.json();return transcripts;};const getTranscriptContent = async (transcriptContentUrl) => {const response = await fetch(`https://xxx.ngrok-free.app/getTranscriptContent?transcriptContentUrl=${transcriptContentUrl}`);if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`);}const content = await response.text();const lines = content.trim().split('\n');const subtitles = [];let currentSpeaker = null;for (let i = 0; i < lines.length; i++) {const line = lines[i].trim();if (line.includes('-->')) {const [startTime, endTime] = line.split(' --> ');const text = lines[i + 1].trim();const speakerMatch = text.match(/<v\s*([^>]+)>/);const speaker = speakerMatch ? speakerMatch[1] : null;const content = text.replace(/<v\s*[^>]*>/, '').replace(/<\/v>/, '');if (speaker && speaker !== currentSpeaker) {currentSpeaker = speaker;}subtitles.push({ startTime, endTime, speaker: currentSpeaker, content });i++; // Skip the next line as it's the text content}}return subtitles;};const displaySubtitle = (subtitle, transcriptElement) => {const subtitleElement = document.createElement('div');subtitleElement.textContent = `${subtitle.speaker}: ${subtitle.content}`;transcriptElement.appendChild(subtitleElement);};const displayTranscripts = (transcripts) => {const transcriptsContainer = document.getElementById('transcripts');transcriptsContainer.innerHTML = ''; // 清空之前的转录信息if (transcripts && transcripts.value && transcripts.value.length > 0) {transcripts.value.forEach(transcript => {const transcriptElement = document.createElement('div');getTranscriptContent(transcript.transcriptContentUrl).then(subtitles => {subtitles.forEach(subtitle => {displaySubtitle(subtitle, transcriptElement);});}).catch(error => {transcriptElement.innerHTML = `<p><strong>${error}</strong></p>`;});transcriptsContainer.appendChild(transcriptElement);});} else {transcriptsContainer.innerText = 'No transcripts found.';}};const init = async () => {microsoftTeams.app.initialize();microsoftTeams.authentication.authenticate({url: `${authUrl}?client_id=${clientId}&response_type=token&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`,width: 600,height: 535,successCallback: async (result) => {console.log('Authentication success:', result);},});try {const response = await fetch('https://shortly-adapted-akita.ngrok-free.app/get_user_token');const data = await response.json();if (response.ok) {user_token = data.user_token;console.log('user token retrieved:', user_token);// Fetch user info using the access tokenconst userInfo = await getUserInfo(user_token);if (userInfo){console.log('User Info:', userInfo);}const joinMeetingId = '45756456529'; // 替换为你要查询的 joinMeetingIdtry {const meetingDetails = await getMeetingDetails(user_token, joinMeetingId);console.log('Meeting Details:', meetingDetails);try {meetingOrganizerUserId = meetingDetails.participants.organizer.identity.user.id;document.getElementById('fetchTranscripts').addEventListener('click', async () => {const organizerId = meetingOrganizerUserIdif (!organizerId) {console.log('Organizer ID is required');return;}try {const transcripts = await getTranscripts(organizerId);console.log(transcripts)displayTranscripts(transcripts);} catch (error) {console.log(`Error: ${error.message}`);}});} catch (error) {console.error('Error fetching transcripts:', error);document.getElementById('transcripts').innerText = 'Error fetching transcripts.';}} catch (error) {console.error('Error fetching meeting details:', error);}console.log('User Token:', data.user_token);} else {console.error('Failed to get token:', response.statusText);}} catch (error) {console.error('Error getting token:', error);}};init();</script>
</body>
</html>

到处代码就开发完毕了。

八、部署调试

1) 点击Teams的打包工具,选择mainfest.json,选择dev即可。

2) dev的配置如下:即注册应用ID,TAB_ENDOINT侧边栏服务端点,TAB_DOMAIN域名

3)登录Developer Portal开发门户,上传appPackage.dev.zip即可。

4)调试,点击即可用将应用安装到chat的某个自己作为主持人权限的会议(如:Teams App Test)中去。

5) 会议中的app

总结:当前的转写只是一次性全显示出来,实际上需要同步实时更新,/deta接口的几种调用方式可以解决问题。

摸索不易,欢迎点赞👍加关注。谢谢!


http://www.ppmy.cn/ops/115176.html

相关文章

华为HarmonyOS灵活高效的消息推送服务(Push Kit) - 1 简介

Push Kit&#xff08;推送服务&#xff09;是华为提供的消息推送平台&#xff0c;建立了从云端到终端的消息推送通道。所有HarmonyOS应用可通过集成Push Kit&#xff0c;实现向应用实时推送消息&#xff0c;使消息易见&#xff0c;构筑良好的用户关系&#xff0c;提升用户的感知…

【已解决】使用JAVA语言实现递归调用-本关任务:用循环和递归算法求 n(小于 10 的正整数) 的阶乘 n!。

本关任务&#xff1a;用循环和递归算法求 n&#xff08;小于 10 的正整数&#xff09; 的阶乘 n!。 测试说明 平台会对你编写的代码进行测试&#xff0c;比对你输出的数值与实际正确数值&#xff0c;只有所有数据全部计算正确才能通过测试&#xff1a; 测试输入&#xff1a;1…

CSS | 如何来避免 FOUC(无样式内容闪烁)现象的发生?

一、什么是 FOUC(无样式内容闪烁)? ‌FOUC&#xff08;Flash of Unstyled Content&#xff09;是指网页在加载过程中&#xff0c;由于CSS样式加载延迟或加载顺序不当&#xff0c;导致页面出现闪烁或呈现出未样式化的内容的现象。‌ 这种现象通常发生在HTML文档已经加载&…

构建数据分析模型,及时回传各系统监控监测数据进行分析反馈响应的智慧油站开源了。

AI视频监控平台简介 AI视频监控平台是一款功能强大且简单易用的实时算法视频监控系统。它的愿景是最底层打通各大芯片厂商相互间的壁垒&#xff0c;省去繁琐重复的适配流程&#xff0c;实现芯片、算法、应用的全流程组合&#xff0c;从而大大减少企业级应用约95%的开发成本。增…

『功能项目』按钮的打开关闭功能【73】

本章项目成果展示 我们打开上一篇72QFrameWork制作背包界面UGUI的项目&#xff0c; 本章要做的事情是制作打开背包与修改器的打开关闭按钮 首先打开UGUICanvas复制button按钮 重命名为ReviseBtn 修改脚本&#xff1a;UIManager.cs 将修改器UI在UGUICanvas预制体中设置为隐藏 运…

如何使用GLib的单向链表GSList

单向链表是一种基础的数据结构&#xff0c;也是一种简单而灵活的数据结构&#xff0c;本文讨论单向链表的基本概念及实现方法&#xff0c;并着重介绍使用GLib的GList实现单向链表的方法及步骤&#xff0c;本文给出了多个实际范例源代码&#xff0c;旨在帮助学习基于GLib编程的读…

使用vite+react+ts+Ant Design开发后台管理项目(二)

前言 本文将引导开发者从零基础开始&#xff0c;运用、react、react-router、react-redux、Ant Design、less、tailwindcss、axios等前沿技术栈&#xff0c;构建一个高效、响应式的后台管理系统。通过详细的步骤和实践指导&#xff0c;文章旨在为开发者揭示如何利用这些技术工具…

iOS 巨魔技巧:一键汉化巨魔商店

嘿&#xff0c;这是黑猫。iOS 巨魔商店一直都有个严重的问题&#xff1a;界面纯英文&#xff0c;不支持简体中文。 当然了&#xff0c;在IT行业&#xff0c;英语是通用语言。但是&#xff0c;既然巨魔/越狱面向普罗大众的技术&#xff0c;那么做好语言适配&#xff0c;还是很关…