您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
An exporter for the Copilot Chat integrated into the M365 dashboard.
// ==UserScript== // @name M365 Copilot Exporter // @namespace ganyuke // @version 1.1.0 // @author ganyuke // @description An exporter for the Copilot Chat integrated into the M365 dashboard. // @license MIT // @icon https://upload.wikimedia.org/wikipedia/commons/0/0e/Microsoft_365_%282022%29.svg // @source https://github.com/ganyuke/copilot-exporter.git // @match https://m365.cloud.microsoft/ // @match https://m365.cloud.microsoft/chat/* // @run-at document-end // ==/UserScript== (function () { 'use strict'; async function hookIntoSidebar(callback) { const sidebarRoot = await waitForElement('[data-testid="appbar-v2"]'); if (!sidebarRoot) { const log = `${APP_TAG} Could not hook into sidebar root!`; console.error(log); throw new Error(log); } const observer = new MutationObserver(() => { const allConversationsBtn = document.getElementById("all-history"); if (allConversationsBtn && allConversationsBtn.dataset.hooked !== "1") { allConversationsBtn.dataset.hooked = "1"; createExportButton(allConversationsBtn, callback); } }); observer.observe(sidebarRoot, { childList: true, subtree: true }); } function waitForElement(selector, timeout = 1e4) { return new Promise((resolve, reject) => { const found = document.querySelector(selector); if (found) return resolve(found); const observer = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); reject(new Error(`Element not found for selector ${selector}`)); }, timeout); }); } function createExportButton(baseBtn, callback) { const exportBtn = baseBtn.cloneNode(true); exportBtn.id = "export-conversations"; exportBtn.setAttribute("aria-label", "Export conversations"); exportBtn.value = "export-conversations"; const span = exportBtn.querySelector("span"); if (span) { span.textContent = "Export conversations"; span.setAttribute("aria-label", "Export conversations"); } exportBtn.addEventListener("click", () => { callback(); }); baseBtn.parentElement?.append(exportBtn); } function injectExportButton(callback) { hookIntoSidebar(callback); } async function fetchCopilotChats(token, userOid, tenantId, maxChats, variants = "feature.EnableLastMessageForGetChats,feature.EnableMRUAgents,feature.EnableHasLoopPages") { const requestObj = { source: "officeweb", traceId: crypto.randomUUID(), // uuid with spaces threadType: "webchat", MaxReturnedChatsCount: maxChats }; const encodedRequest = encodeURIComponent(JSON.stringify(requestObj)); const encodedVariants = encodeURIComponent(variants); const url = `https://substrate.office.com/m365Copilot/GetChats?request=${encodedRequest}&variants=${encodedVariants}`; const headers = { "authorization": `Bearer ${token}`, "content-type": "application/json", "x-anchormailbox": `Oid:${userOid}@${tenantId}`, "x-clientrequestid": crypto.randomUUID().replace(/-/g, ""), // uuid *without* spaces "x-routingparameter-sessionkey": userOid, "x-scenario": "OfficeWebIncludedCopilot", "x-variants": variants }; const res = await fetch(url, { method: "GET", headers }); if (!res.ok) { console.debug(res); console.debug(res.body); throw new Error(`Fetch failed with status ${res.status}`); } const data = await res.json(); return data; } async function fetchCopilotConversation(token, userOid, tenantId, conversationId) { const requestObj = { conversationId, source: "officeweb", traceId: crypto.randomUUID().replace(/-/g, "") // uuid *without* spaces (for some reason??) }; const encodedRequest = encodeURIComponent(JSON.stringify(requestObj)); const url = `https://substrate.office.com/m365Copilot/GetConversation?request=${encodedRequest}`; const headers = { "authorization": `Bearer ${token}`, "content-type": "application/json", "x-anchormailbox": `Oid:${userOid}@${tenantId}`, "x-clientrequestid": crypto.randomUUID().replace(/-/g, ""), // also UUID w/o spaces "x-routingparameter-sessionkey": userOid, "x-scenario": "OfficeWebIncludedCopilot" }; const response = await fetch(url, { method: "GET", headers }); if (!response.ok) { console.debug(response); console.debug(response.body); throw new Error(`Fetch failed with status ${response.status}`); } return await response.blob(); } async function deleteCopilotConversation(token, userOid, tenantId, conversationIds) { const requestObj = { conversationIdsToDelete: conversationIds, source: "officeweb", traceId: crypto.randomUUID() // honestly don't really know the pattern whith these uuids... }; const encodedRequest = JSON.stringify(requestObj); const url = `https://substrate.office.com/m365Copilot/DeleteConversation`; const headers = { "authorization": `Bearer ${token}`, "content-type": "application/json", "x-anchormailbox": `Oid:${userOid}@${tenantId}`, "x-clientrequestid": crypto.randomUUID(), "x-routingparameter-sessionkey": userOid, "x-scenario": "OfficeWebIncludedCopilot" }; const response = await fetch(url, { method: "POST", headers, body: encodedRequest }); if (!response.ok) { console.debug(response); console.debug(response.body); throw new Error(`Fetch failed with status ${response.status}`); } return; } function downloadBlobAsFile(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } const getCookie = (key) => document.cookie.match(`(^|;)\\s*${key}\\s*=\\s*([^;]+)`)?.pop() || ""; const ENCRYPTION_KEY = "msal.cache.encryption"; const AES_GCM = "AES-GCM"; const HKDF = "HKDF"; const S256_HASH_ALG = "SHA-256"; const RAW = "raw"; const ENCRYPT = "encrypt"; const DECRYPT = "decrypt"; const DERIVE_KEY = "deriveKey"; function base64DecToArr(base64String) { let encodedString = base64String.replace(/-/g, "+").replace(/_/g, "/"); switch (encodedString.length % 4) { case 0: break; case 2: encodedString += "=="; break; case 3: encodedString += "="; break; default: throw Error("error extracting base64"); } const binString = atob(encodedString); return Uint8Array.from(binString, (m) => m.codePointAt(0) || 0); } async function deriveKey(baseKey, nonce, context) { return window.crypto.subtle.deriveKey( { name: HKDF, salt: nonce, hash: S256_HASH_ALG, info: new TextEncoder().encode(context) }, baseKey, { name: AES_GCM, length: 256 }, false, [ENCRYPT, DECRYPT] ); } async function decrypt(baseKey, nonce, context, encryptedData) { const encodedData = base64DecToArr(encryptedData); const derivedKey = await deriveKey(baseKey, base64DecToArr(nonce), context); const decryptedData = await window.crypto.subtle.decrypt( { name: AES_GCM, iv: new Uint8Array(12) // New key is derived for every encrypt so we don't need a new nonce }, derivedKey, encodedData ); return new TextDecoder().decode(decryptedData); } function generateHKDF(baseKey) { return window.crypto.subtle.importKey(RAW, baseKey, HKDF, false, [ DERIVE_KEY ]); } async function getEncryptionCookie() { const cookieString = decodeURIComponent(getCookie(ENCRYPTION_KEY)); let parsedCookie = { key: "", id: "" }; if (cookieString) { try { parsedCookie = JSON.parse(cookieString); } catch (e) { throw Error("failed to parse encryption cookie"); } } if (parsedCookie.key && parsedCookie.id) { const baseKey = base64DecToArr(parsedCookie.key); return { id: parsedCookie.id, key: await generateHKDF(baseKey) }; } else { throw Error("no encryption cookie found"); } } const getMsalIds = () => { const clientId = "c0ab8ce9-e9a0-42e7-b064-33d422df41f1"; const identityBlock = document.getElementById("identity"); if (!identityBlock || !identityBlock.textContent) { throw new Error("missing user identity block"); } const { objectId: localAccountId, tenantId } = JSON.parse(identityBlock.textContent); return { localAccountId, tenantId, homeAccountId: `${localAccountId}.${tenantId}`, clientId }; }; const getAccessToken = async (msalIds) => { const encryptionCookie = await getEncryptionCookie(); const { homeAccountId, tenantId, clientId } = msalIds; const SCOPES = [ "https://substrate.office.com/sydney/.default" ]; const ACCESS_TOKEN_LS = `${homeAccountId}-login.windows.net-accesstoken-${clientId}-${tenantId}-${SCOPES.join(" ")}--`; const lskv = localStorage.getItem(ACCESS_TOKEN_LS); if (!lskv) { throw Error("missing access token localstorage"); } const payload = JSON.parse(lskv); const decryptedData = await decrypt( encryptionCookie.key, payload.nonce, clientId, // context is usually client ID according to MSAL v4 source code: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/afeaeddc777577b1b16f0084f5e5f9e4c15ee5e9/lib/msal-browser/src/cache/LocalStorage.ts#L302 payload.data ); const parsedDecryptedData = JSON.parse(decryptedData); return parsedDecryptedData.secret; }; const FETCH_DELAY = 1500; async function getTokenAndIds() { console.log(`${APP_TAG} Getting MSAL ids...`); const msalIds = getMsalIds(); console.log(`${APP_TAG} Getting access token...`); const accessToken = await getAccessToken(msalIds); return { token: accessToken, ...msalIds }; } async function exportBulkDirect(conversationIds, callback) { const { token, localAccountId, tenantId } = await getTokenAndIds(); for (let i = 0; i < conversationIds.length; i++) { const conversationId = conversationIds[i]; const blob = await fetchCopilotConversation(token, localAccountId, tenantId, conversationId); console.log(`${APP_TAG} Completed download for conversation ${conversationId}`); callback(i); downloadBlobAsFile(blob, `m365-copilot-${conversationId}.json`); await new Promise((resolve) => setTimeout(resolve, FETCH_DELAY)); } } async function deleteBulk(conversationIds, callback) { const { token, localAccountId, tenantId } = await getTokenAndIds(); await deleteCopilotConversation(token, localAccountId, tenantId, conversationIds); callback(conversationIds.length - 1); console.log(`${APP_TAG} Completed deletion for conversations ${conversationIds.join()}`); } function showExportModal() { if (document.getElementById("copilotExportOverlay")) return; const overlay = document.createElement("div"); overlay.id = "copilotExportOverlay"; overlay.style.cssText = ` position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; `; overlay.addEventListener("click", () => { overlay.remove(); }); const modal = document.createElement("div"); modal.addEventListener("click", (e) => { e.stopPropagation(); }); modal.style.cssText = ` background: white; padding: 20px; border-radius: 8px; min-width: 400px; max-width: 90%; box-shadow: 0 4px 10px rgba(0,0,0,0.2); font-family: sans-serif; `; modal.innerHTML = ` <h2 style="margin-top:0;">Export conversations</h2> <p style="margin-bottom: 1em;">Export from API</p> <div style="display:flex;column-gap:0.5em;"> <label style="flex-grow:1;" for="conversation-fetch-list-max">Max conversations to fetch</label> <input type="number" id="conversation-fetch-list-max" name="quantity" min="0"> <button id="conversation-refetch">Refetch</button> </div> <div style="margin: 1em 0; border: 1px solid #ccc; padding: 0.5em; max-height: 200px; overflow-y: auto;"> <label><input type="checkbox" id="selectAllCheckbox"> Select All</label> <div id="chatList" style="margin-top: 0.5em; color: #666">Loading…</div> </div> <div style="display: flex; justify-content: space-between; align-items: center;"> <select> <option>JSON</option> </select> <div> <button id="delete-conversations-button">Delete</button> <button id="export-conversations-button">Export</button> </div> </div> `; overlay.appendChild(modal); document.body.appendChild(overlay); function createProgressBar(idsToExport, initalString) { if (idsToExport.length < 1) { return; } const existingProgressBarContainer = document.querySelector("#chat-export-progress-bar-container"); if (existingProgressBarContainer) { return; } const progressBarContainer = document.createElement("div"); progressBarContainer.id = "chat-export-progress-bar-container"; progressBarContainer.style = "display: flex;flex-direction: column;margin-top: 0.5em;"; const progressBar = document.createElement("progress"); const label = document.createElement("label"); label.style = "display:flex;"; const titleSpan = document.createElement("span"); const progressTextSpan = document.createElement("span"); titleSpan.style = "flex-grow:1;"; progressBar.id = "chat-export-progress-bar"; progressBar.max = idsToExport.length; progressBar.value = 0; label.htmlFor = "chat-export-progress-bar"; titleSpan.textContent = initalString; progressTextSpan.textContent = `0/${idsToExport.length}`; label.append(titleSpan, progressTextSpan); progressBarContainer.append(label, progressBar); modal.append(progressBarContainer); const progressUpdater = (progress) => { titleSpan.textContent = idsToExport[progress].title; progressTextSpan.textContent = `${progress + 1}/${idsToExport.length}`; progressBar.value = progress + 1; if (progressBar.value === progressBar.max) { setTimeout(() => { progressBarContainer.remove(); }, 3e3); } }; return progressUpdater; } async function fetchChats() { const inputNumber = document.getElementById("conversation-fetch-list-max"); const n = inputNumber.valueAsNumber; const maxChats = isNaN(n) ? 15 : n; console.log(`${APP_TAG} Getting MSAL ids...`); const msalIds = getMsalIds(); console.log(`${APP_TAG} Getting access token...`); const accessToken = await getAccessToken(msalIds); const copilotChatList = await fetchCopilotChats(accessToken, msalIds.localAccountId, msalIds.tenantId, maxChats); const chatList = document.getElementById("chatList"); chatList.innerText = ""; copilotChatList.chats.forEach((data) => { const label = document.createElement("label"); label.style = "column-gap:0.5em;display:flex;"; const checkbox = document.createElement("input"); const span = document.createElement("span"); checkbox.type = "checkbox"; checkbox.dataset["id"] = data.conversationId; checkbox.dataset["title"] = data.chatName; span.innerText = data.chatName; label.append(checkbox); label.append(span); chatList.appendChild(label); }); const selectAll = document.getElementById("selectAllCheckbox"); selectAll.addEventListener("change", () => { const checkboxes = document.querySelectorAll('#chatList input[type="checkbox"]'); checkboxes.forEach((cb) => { cb.checked = selectAll.checked; }); }); } function getSelectedChats() { const checkboxes = document.querySelectorAll('#chatList input[type="checkbox"]:checked'); const listToExport = []; checkboxes.forEach((c) => { const uuid = c.dataset["id"]; const title = c.dataset["title"]; listToExport.push({ id: uuid, title }); }); return listToExport; } function exportChats() { const idsToExport = getSelectedChats(); const progressUpdater = createProgressBar(idsToExport, "Exporting..."); if (!progressUpdater) { return; } exportBulkDirect(idsToExport.map((obj) => obj.id), progressUpdater); } function deleteChats() { const idsToDelete = getSelectedChats(); const progressUpdater = createProgressBar(idsToDelete, "Deleting..."); if (!progressUpdater) { return; } deleteBulk(idsToDelete.map((obj) => obj.id), progressUpdater); } const exportBtn = document.getElementById("export-conversations-button"); exportBtn.addEventListener("click", exportChats); const deleteBtn = document.getElementById("delete-conversations-button"); deleteBtn.addEventListener("click", deleteChats); const refetchButton = document.getElementById("conversation-refetch"); refetchButton.addEventListener("click", fetchChats); fetchChats(); } const APP_TAG = "[Copilot Exporter]"; console.log(`${APP_TAG} Userscript initalized.`); const inject = () => injectExportButton( () => { console.log(`${APP_TAG} Export button clicked.`); showExportModal(); } ); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", inject); } else { inject(); } })();