M365 Copilot Exporter

An exporter for the Copilot Chat integrated into the M365 dashboard.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
  }

})();