在Teams会议侧边栏应用开发-会议转写-CSDN博客的基础上,使用/delta接口尝试获取实时转写,发现只能更新了一次,然后就不再更新了,想尝试使用订阅事件去获取转写,发现也不是实时的,当会议结束时,订阅事件才会产生,且有一定的延迟。本文主要针对订阅事件如何创建及事件接收后如何处理进行描述。
转写免费配额(很容易使用完,可以重新注册一个新的应用再次获取免费配额):
转写计费成本:
订阅事件的创建:
// 定义 /subscribe 端点
server.post('/subscribe', async (req, res) => {
try {
const onlineMeetingId = req.query.meetingId;
console.log('/subscribe meetingId:',onlineMeetingId);
const response = await fetch("https://graph.microsoft.com/beta/subscriptions", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${app_token}`,
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error.message);
}
const data = await response.json();
let noSub = true;
data.value.forEach(sub=>{
if (sub.resource === `communications/onlineMeetings/${onlineMeetingId}/transcripts`){
noSub = false;
}
})
if (noSub){
const subscription = {
changeType: "created",
notificationUrl: "https://shortly-adapted-akita.ngrok-free.app/webhook",
resource: `communications/onlineMeetings/${onlineMeetingId}/transcripts`,
expirationDateTime: new Date(Date.now() + 60 * 60 * 1000).toISOString(), //1小时
clientState: "secretClientValue",
};
const response2 = await fetch("https://graph.microsoft.com/beta/subscriptions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${app_token}`,
},
body: JSON.stringify(subscription),
});
if (!response2.ok) {
const errorData = await response2.json();
throw new Error(errorData.error.message);
}
const data2 = await response2.json();
console.log("Subscription created:", data2.id);
res.send(200, data2);
}
} catch (error) {
console.error("Error creating subscription:", error);
res.send(500, { error: error.message });
}
});
订阅事件的接收(订阅事件没有使用加密方式,需要调用2次API获取转写):
// 定义 /webhook 端点
server.post('/webhook', async (req, res) => {
const validationToken = req.query.validationToken;
console.log('/webhook validationToken:', validationToken);
// 如果是验证请求,返回 validationToken
if (validationToken) {
res.setHeader('Content-Type', 'text/plain');
res.send(200, validationToken);
return;
}
const notification = req.body.value[0];
const odataId = notification.resourceData['@odata.id'];
const url = `https://graph.microsoft.com/beta/${odataId}`.replace('communications','me');
//console.log('url:', url);
const response = await fetch('https://shortly-adapted-akita.ngrok-free.app/get_user_token');
const token_data = await response.json();
// 获取具体的转写数据
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token_data.user_token}`,
},
});
if (!response.ok) {
const errorData = await response.json();
//console.log(errorData);
throw new Error(errorData.error.message);
}
const transcriptData = await response.json();
//console.log("Transcript Data:", transcriptData);
// 获取转写内容
const transcriptContentUrl = transcriptData.transcriptContentUrl;
const contentResponse = await fetch(`${transcriptContentUrl}?$format=text/vtt`, {
headers: {
Authorization: `Bearer ${token_data.user_token}`,
},
});
if (!contentResponse.ok) {
const errorData = await contentResponse.text();
//console.log(errorData);
throw new Error(errorData);
}
const transcriptContent = await contentResponse.text();
console.log("Transcript Content:", transcriptContent);
// 将转写数据发送到所有连接的 WebSocket 客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'newTranscript', data: transcriptContent }));
}
});
} catch (error) {
console.error("Error fetching transcript data:", error);
}
res.send(200, "OK");
});
完整的服务端代码如下:
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';
import { WebSocketServer, WebSocket } from 'ws';
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 storage
async 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 token
handleToken();
</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 token
await 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 token
const 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 = '';
let app_token_expires_at = 0;
const app_token_refresh_interval = 3000 * 1000; // 3000秒
const getAppToken = async () => {
try {
// 构建请求体
const requestBody = new URLSearchParams({
"grant_type": "client_credentials",
"client_id": "Azure注册应用ID",
"client_secret": "Azure注册应用密码",
"scope": "https://graph.microsoft.com/.default",
}).toString();
// 获取app令牌
const tokenUrl = `https://login.microsoftonline.com/注册应用租户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();
throw new Error(errorData.error_description);
}
const tokenData = await tokenResponse.json();
app_token = tokenData.access_token;
app_token_expires_at = Date.now() + app_token_refresh_interval;
console.log("app_token received!");
} catch (error) {
console.error('Error getting app token:', error);
}
};
// 定期刷新 app_token
setInterval(getAppToken, app_token_refresh_interval);
// 定义 /getTranscripts 端点
server.get('/getTranscripts', async (req, res) => {
try {
const url = req.query.url;
if (!url) {
res.send(400, { error: 'URL is required' });
return;
}
// 调用 Microsoft Graph API
const graphResponse = await fetch(url, {
headers: {
Authorization: `Bearer ${app_token}`,
},
});
if (!graphResponse.ok) {
const errorData = await graphResponse.json();
res.send(500, { error: errorData.error.message });
return;
}
const data = await graphResponse.json();
const currentTime = new Date().toISOString(); // 获取当前时间
console.log(currentTime, ', getTranscripts length:',data.value.length);
// 返回转录文本
res.send(200, data);
} catch (error) {
// 返回错误
res.send(500, { error: error.message });
}
});
// 定义 /getTranscriptContent 端点
let callCount = 0; // 调用计数器
server.get('/getTranscriptContent', async (req, res) => {
try {
const response = await fetch('https://shortly-adapted-akita.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' });
return;
}
const content_url = `${transcriptContentUrl}?$format=text/vtt`;
// 调用 Microsoft Graph API
const 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 });
return;
}
const data = await graphResponse.text();
callCount++; // 增加正确调用计数
const currentTime = new Date().toISOString(); // 获取当前时间
console.log(`getTranscriptContent called at ${currentTime}, call count: ${callCount}`); // 输出日志
console.log('content:', data)
// 返回转录文本
res.send(200, data);
} catch (error) {
// 返回错误
res.send(500, { error: error.message });
}
});
// 确保在服务器启动时获取 app_token
getAppToken();
// 初始化 WebSocket 服务器
const wss = new WebSocketServer({ server: server.server });
wss.on('connection', (ws) => {
console.log('A client connected');
ws.on('message', (message) => {
console.log(`Received message: ${message}`);
});
ws.on('close', () => {
console.log('A client disconnected');
});
});
// 定义 /subscribe 端点
server.post('/subscribe', async (req, res) => {
try {
const onlineMeetingId = req.query.meetingId;
console.log('/subscribe meetingId:',onlineMeetingId);
const response = await fetch("https://graph.microsoft.com/beta/subscriptions", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${app_token}`,
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error.message);
}
const data = await response.json();
let noSub = true;
data.value.forEach(sub=>{
if (sub.resource === `communications/onlineMeetings/${onlineMeetingId}/transcripts`){
noSub = false;
}
})
if (noSub){
const subscription = {
changeType: "created",
notificationUrl: "https://shortly-adapted-akita.ngrok-free.app/webhook",
resource: `communications/onlineMeetings/${onlineMeetingId}/transcripts`,
expirationDateTime: new Date(Date.now() + 60 * 60 * 1000).toISOString(), //1小时
clientState: "secretClientValue",
};
const response2 = await fetch("https://graph.microsoft.com/beta/subscriptions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${app_token}`,
},
body: JSON.stringify(subscription),
});
if (!response2.ok) {
const errorData = await response2.json();
throw new Error(errorData.error.message);
}
const data2 = await response2.json();
console.log("Subscription created:", data2.id);
res.send(200, data2);
}
} catch (error) {
console.error("Error creating subscription:", error);
res.send(500, { error: error.message });
}
});
// 定义 /webhook 端点
server.post('/webhook', async (req, res) => {
const validationToken = req.query.validationToken;
console.log('/webhook validationToken:', validationToken);
// 如果是验证请求,返回 validationToken
if (validationToken) {
res.setHeader('Content-Type', 'text/plain');
res.send(200, validationToken);
return;
}
const notification = req.body.value[0];
const odataId = notification.resourceData['@odata.id'];
const url = `https://graph.microsoft.com/beta/${odataId}`.replace('communications','me');
//console.log('url:', url);
const response = await fetch('https://shortly-adapted-akita.ngrok-free.app/get_user_token');
const token_data = await response.json();
// 获取具体的转写数据
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token_data.user_token}`,
},
});
if (!response.ok) {
const errorData = await response.json();
//console.log(errorData);
throw new Error(errorData.error.message);
}
const transcriptData = await response.json();
//console.log("Transcript Data:", transcriptData);
// 获取转写内容
const transcriptContentUrl = transcriptData.transcriptContentUrl;
const contentResponse = await fetch(`${transcriptContentUrl}?$format=text/vtt`, {
headers: {
Authorization: `Bearer ${token_data.user_token}`,
},
});
if (!contentResponse.ok) {
const errorData = await contentResponse.text();
//console.log(errorData);
throw new Error(errorData);
}
const transcriptContent = await contentResponse.text();
console.log("Transcript Content:", transcriptContent);
// 将转写数据发送到所有连接的 WebSocket 客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'newTranscript', data: transcriptContent }));
}
});
} catch (error) {
console.error("Error fetching transcript data:", error);
}
res.send(200, "OK");
});
完整的页面代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Transcripts</title>
<script src="https://res.cdn.office.net/teams-js/2.0.0/js/MicrosoftTeams.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<style>
.subtitle {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.speaker-photo {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 10px;
}
</style>
</head>
<body>
<h2>Meeting Transcripts</h2>
<div id="transcripts"></div>
<script>
const clientId = 'Azure注册应用ID';
const tenantId = 'Azure注册应用租户ID';
const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
const redirectUri = '你的https域名/auth'; // 确保与服务器端一致
const scope = 'user.read';
let user_token = null;
let meetingOrganizerUserId = null;
let participants = {}; // 用于存储参会者的信息
let nextLink = null;
let deltaLink = null;
let userPhotoCache = {}; // 用于缓存用户头像
let tokenFetched = false; // 标志变量,用于跟踪是否已经获取了 user_token
const getUserInfo = async (userId, accessToken) => {
const graphUrl = `https://graph.microsoft.com/v1.0/users/${userId}`;
const response = await fetch(graphUrl, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.status === 401) {
// 如果 token 超期,重新触发 initAuthentication
initAuthentication();
return null;
}
const userInfo = await response.json();
return userInfo;
};
const getUserPhoto = async (userId, accessToken) => {
if (userPhotoCache[userId]) {
return userPhotoCache[userId];
}
const graphUrl = `https://graph.microsoft.com/v1.0/users/${userId}/photo/$value`;
const response = await fetch(graphUrl, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
const errorData = await response.json();
console.error('Error fetching user photo:', errorData);
return null;
}
const photoBlob = await response.blob();
const photoUrl = URL.createObjectURL(photoBlob);
userPhotoCache[userId] = photoUrl; // 缓存头像 URL
return photoUrl;
};
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) {
const errorData = await response.json();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.error}`);
}
const data = await response.json();
return data.value[0];
};
const getTranscriptContent = async (content) => {
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 = async (subtitle, transcriptElement, accessToken) => {
const subtitleElement = document.createElement('div');
subtitleElement.classList.add('subtitle');
// 获取说话者的头像
const speakerUserId = participants[subtitle.speaker];
const speakerPhotoUrl = speakerUserId ? await getUserPhoto(speakerUserId, accessToken) : 'default-avatar.png';
// 创建头像元素
const speakerPhotoElement = document.createElement('img');
speakerPhotoElement.src = speakerPhotoUrl;
speakerPhotoElement.alt = subtitle.speaker;
speakerPhotoElement.classList.add('speaker-photo');
// 创建输出字符串
const output = `${subtitle.startTime}\n${subtitle.content}`;
subtitleElement.appendChild(speakerPhotoElement);
subtitleElement.appendChild(document.createTextNode(output));
transcriptElement.appendChild(subtitleElement);
};
const subscribeToWebhook = async (meetingId) => {
try {
const subscribeResponse = await fetch(`https://shortly-adapted-akita.ngrok-free.app/subscribe?meetingId=${meetingId}`, {
method: 'POST'
});
if (!subscribeResponse.ok) {
const errorData = await subscribeResponse.json();
console.error('Webhook subscription failed:', errorData);
} else {
console.log('Webhook subscription successful');
}
} catch (error) {
console.error('Error subscribing to webhook:', error);
}
};
const init = async () => {
try {
if (!tokenFetched) {
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);
tokenFetched = true;
} else {
console.error('Failed to get token:', response.statusText);
return;
}
}
const joinMeetingId = '45756456529'; // 替换为你要查询的 joinMeetingId
try {
const meetingDetails = await getMeetingDetails(user_token, joinMeetingId);
console.log('Meeting Details:', meetingDetails);
meetingOrganizerUserId = meetingDetails.participants.organizer.identity.user.id;
const meetingId = meetingDetails.id; // 获取会议 ID
console.log('Organizer User ID:', meetingOrganizerUserId);
console.log('Meeting ID:', meetingId);
// 获取主持人信息
const organizerInfo = await getUserInfo(meetingOrganizerUserId, user_token);
const organizerDisplayName = organizerInfo.displayName;
participants[organizerDisplayName] = meetingOrganizerUserId;
// 获取参会者信息
const attendeesPromises = meetingDetails.participants.attendees.map(async attendee => {
const userId = attendee.identity.user.id;
const userInfo = await getUserInfo(userId, user_token);
const displayName = userInfo.displayName;
participants[displayName] = userId;
});
await Promise.all(attendeesPromises);
// 初始化历史转写
// await fetchTranscripts();
// 订阅 Webhook
await subscribeToWebhook(meetingId);
// 设置每小时调用一次订阅 Webhook
setInterval(() => subscribeToWebhook(meetingId), 3600000); // 每3600秒(1小时)调用一次
} catch (error) {
console.error('Error fetching meeting details:', error);
}
console.log('User Token:', user_token);
} catch (error) {
console.error('Error getting token:', error);
}
};
const initAuthentication = () => {
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);
},
failureCallback: (error) => {
console.error('Authentication failed:', error);
}
});
};
// 设置较长的轮询时间来防止 user_token 的超期
setInterval(initAuthentication, 3000000); // 每3000秒(50分钟)轮询一次
initAuthentication();
init();
// 初始化 WebSocket 客户端
const socket = new WebSocket('wss://shortly-adapted-akita.ngrok-free.app');
socket.onopen = () => {
console.log('WebSocket connection established');
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log(message);
if (message.type === 'newTranscript') {
const transcriptData = message.data;
console.log('New Transcript Data:', transcriptData);
const transcriptsContainer = document.getElementById('transcripts');
const transcriptElement = document.createDocumentFragment(); // 使用 DocumentFragment 优化 DOM 操作
getTranscriptContent(transcriptData)
.then(subtitles => {
subtitles.forEach(subtitle => {
displaySubtitle(subtitle, transcriptElement, user_token);
});
})
.catch(error => {
const errorElement = document.createElement('div');
errorElement.innerHTML = `<strong>${error}</strong>`;
transcriptElement.appendChild(errorElement);
})
.finally(() => {
transcriptsContainer.appendChild(transcriptElement); // 一次性插入 DOM
});
}
};
socket.onclose = () => {
console.log('WebSocket connection closed');
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
</script>
</body>
</html>
输出效果:
摸索不易,欢迎点赞👍加关注!