@mindstone/mcp-server-microsoft-teams 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/resources/compose-message-template.d.ts +2 -0
- package/dist/resources/compose-message-template.js +968 -0
- package/dist/server.js +5 -1
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +75 -1
- package/package.json +5 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to this connector will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-07-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Add compose_chat_message interactive compose view (MCP App); send_chat_message input schema is now strict (unknown keys rejected)
|
|
12
|
+
|
|
7
13
|
## [0.1.2] - 2026-07-03
|
|
8
14
|
|
|
9
15
|
### Changed
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Microsoft 365 Teams MCP server — list and read Teams chats, send chat messages
|
|
|
9
9
|
|
|
10
10
|
## Status
|
|
11
11
|
|
|
12
|
-
- **Version:** [0.
|
|
12
|
+
- **Version:** [0.2.0](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-microsoft-teams)
|
|
13
13
|
- **Auth:** OAuth (host-orchestrated, shared with [`mcp-server-microsoft-mail`](../microsoft-mail/)) ([`MS_CLIENT_ID`](./server.json))
|
|
14
14
|
- **Tools:** [7](./src/tools.ts) (chats, messages, teams, channels, presence)
|
|
15
15
|
- **Surface:** cloud-api
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const COMPOSE_MESSAGE_HTML = "\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Compose message</title>\n <style>\n :root {\n --bg: #ffffff;\n --panel: #ffffff;\n --border: #d9dde5;\n --text: #1f2937;\n --muted: #6b7280;\n --input-bg: #ffffff;\n --button-bg: #1f6feb;\n --button-text: #ffffff;\n --button-bg-hover: #1b63d3;\n --secondary-bg: #f3f4f6;\n --secondary-text: #1f2937;\n --secondary-border: #d1d5db;\n --error-bg: #fef2f2;\n --error-border: #fecaca;\n --error-text: #991b1b;\n --success-bg: #ecfdf5;\n --success-border: #bbf7d0;\n --success-text: #166534;\n --warning-bg: #fffbeb;\n --warning-border: #fde68a;\n --warning-text: #92400e;\n }\n\n @media (prefers-color-scheme: dark) {\n :root {\n --bg: #0f172a;\n --panel: #111827;\n --border: #334155;\n --text: #e5e7eb;\n --muted: #9ca3af;\n --input-bg: #0b1220;\n --button-bg: #3b82f6;\n --button-text: #eff6ff;\n --button-bg-hover: #2563eb;\n --secondary-bg: #1f2937;\n --secondary-text: #e5e7eb;\n --secondary-border: #374151;\n --error-bg: #2b1011;\n --error-border: #7f1d1d;\n --error-text: #fca5a5;\n --success-bg: #052e1b;\n --success-border: #166534;\n --success-text: #86efac;\n --warning-bg: #2a2106;\n --warning-border: #854d0e;\n --warning-text: #fcd34d;\n }\n }\n\n html[data-theme=\"light\"] {\n color-scheme: light;\n }\n\n html[data-theme=\"dark\"] {\n color-scheme: dark;\n --bg: #0f172a;\n --panel: #111827;\n --border: #334155;\n --text: #e5e7eb;\n --muted: #9ca3af;\n --input-bg: #0b1220;\n --button-bg: #3b82f6;\n --button-text: #eff6ff;\n --button-bg-hover: #2563eb;\n --secondary-bg: #1f2937;\n --secondary-text: #e5e7eb;\n --secondary-border: #374151;\n --error-bg: #2b1011;\n --error-border: #7f1d1d;\n --error-text: #fca5a5;\n --success-bg: #052e1b;\n --success-border: #166534;\n --success-text: #86efac;\n --warning-bg: #2a2106;\n --warning-border: #854d0e;\n --warning-text: #fcd34d;\n }\n\n * {\n box-sizing: border-box;\n }\n\n body {\n margin: 0;\n padding: 16px;\n background: var(--bg);\n color: var(--text);\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n line-height: 1.4;\n }\n\n .wrapper {\n width: 100%;\n max-width: 720px;\n margin: 0 auto;\n }\n\n .card {\n background: var(--panel);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 14px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n }\n\n .field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n\n .field label {\n font-size: 12px;\n font-weight: 600;\n color: var(--muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n\n .from-value {\n color: var(--text);\n font-size: 14px;\n padding: 2px 0;\n word-break: break-word;\n }\n\n .from-helper {\n font-size: 12px;\n color: var(--muted);\n margin-top: 2px;\n }\n\n .input,\n .textarea {\n width: 100%;\n background: var(--input-bg);\n color: var(--text);\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 10px 12px;\n font-size: 14px;\n font-family: inherit;\n }\n\n .input:focus,\n .textarea:focus {\n outline: 2px solid rgba(59, 130, 246, 0.35);\n outline-offset: 1px;\n }\n\n .textarea {\n min-height: 180px;\n resize: vertical;\n white-space: pre-wrap;\n }\n\n .toggles {\n display: flex;\n gap: 12px;\n align-items: center;\n flex-wrap: wrap;\n }\n\n .link-button {\n border: none;\n padding: 0;\n background: none;\n color: var(--muted);\n text-decoration: underline;\n font-size: 12px;\n cursor: pointer;\n }\n\n .link-button:hover {\n color: var(--text);\n }\n\n .actions {\n display: flex;\n justify-content: flex-end;\n gap: 10px;\n margin-top: 2px;\n }\n\n .button {\n border-radius: 8px;\n border: 1px solid transparent;\n min-height: 36px;\n padding: 0 14px;\n font-size: 13px;\n font-weight: 600;\n font-family: inherit;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n transition: background 120ms ease, opacity 120ms ease, border-color 120ms ease;\n }\n\n .button:disabled {\n cursor: not-allowed;\n opacity: 0.75;\n }\n\n .button-primary {\n background: var(--button-bg);\n color: var(--button-text);\n }\n\n .button-primary:hover:enabled {\n background: var(--button-bg-hover);\n }\n\n .button-secondary {\n background: var(--secondary-bg);\n color: var(--secondary-text);\n border-color: var(--secondary-border);\n }\n\n .spinner {\n width: 14px;\n height: 14px;\n border: 2px solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n display: inline-block;\n animation: spin 700ms linear infinite;\n }\n\n @keyframes spin {\n to { transform: rotate(360deg); }\n }\n\n .status {\n border-radius: 8px;\n border: 1px solid transparent;\n padding: 8px 10px;\n font-size: 13px;\n }\n\n .error {\n background: var(--error-bg);\n border-color: var(--error-border);\n color: var(--error-text);\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n }\n\n .success {\n background: var(--success-bg);\n border-color: var(--success-border);\n color: var(--success-text);\n }\n\n .warning {\n background: var(--warning-bg);\n border-color: var(--warning-border);\n color: var(--warning-text);\n display: block;\n }\n\n .warning strong {\n display: block;\n margin-bottom: 2px;\n }\n\n .warning .warning-detail {\n opacity: 0.85;\n }\n\n .collapsed {\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--panel);\n padding: 10px 12px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n font-size: 13px;\n }\n\n .hidden {\n display: none !important;\n }\n\n .sent-header {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n }\n\n .sent-badge {\n display: inline-flex;\n align-items: center;\n background: var(--success-bg);\n color: var(--success-text);\n border: 1px solid var(--success-border);\n border-radius: 999px;\n padding: 2px 10px;\n font-size: 12px;\n font-weight: 600;\n }\n\n .sent-timestamp {\n color: var(--muted);\n font-size: 12px;\n }\n\n .sent-value {\n color: var(--text);\n font-size: 14px;\n word-break: break-word;\n }\n\n .sent-body {\n color: var(--text);\n font-size: 14px;\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 260px;\n overflow: auto;\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 10px 12px;\n background: var(--input-bg);\n }\n </style>\n</head>\n<body>\n <div class=\"wrapper\">\n <div id=\"collapsedState\" class=\"collapsed hidden\">\n <span id=\"collapsedMessage\">Draft collapsed.</span>\n <button id=\"reopenButton\" type=\"button\" class=\"button button-secondary\">Reopen</button>\n </div>\n\n <form id=\"composeForm\" class=\"card\" novalidate aria-label=\"Compose message\">\n <div class=\"field\">\n <span id=\"workspaceLine\" class=\"from-helper hidden\"></span>\n </div>\n\n <div class=\"field\">\n <label for=\"toInput\">To</label>\n <input id=\"toInput\" class=\"input\" type=\"text\" autocomplete=\"off\" placeholder=\"Chat ID\">\n </div>\n\n <div class=\"field\">\n <label for=\"bodyInput\">Message</label>\n <textarea id=\"bodyInput\" class=\"textarea\" placeholder=\"Write your message\"></textarea>\n </div>\n\n <div id=\"errorBox\" class=\"status error hidden\" role=\"alert\">\n <span id=\"errorText\"></span>\n <button id=\"retryButton\" type=\"button\" class=\"link-button\">Retry</button>\n </div>\n\n <div id=\"successBox\" class=\"status success hidden\" role=\"status\" aria-live=\"polite\"></div>\n\n <div id=\"unknownBox\" class=\"status warning hidden\" role=\"status\" aria-live=\"polite\">\n <strong id=\"unknownTitle\"></strong>\n <span id=\"unknownText\" class=\"warning-detail\"></span>\n </div>\n\n <div class=\"actions\">\n <button id=\"cancelButton\" type=\"button\" class=\"button button-secondary\">Cancel</button>\n <button id=\"sendButton\" type=\"button\" class=\"button button-primary\">\n <span id=\"sendSpinner\" class=\"spinner hidden\" aria-hidden=\"true\"></span>\n <span id=\"sendLabel\">Send message</span>\n </button>\n </div>\n </form>\n\n <div id=\"sentView\" class=\"card hidden\" role=\"status\" aria-live=\"polite\">\n <div class=\"sent-header\">\n <span class=\"sent-badge\">Sent</span>\n <span id=\"sentTimestamp\" class=\"sent-timestamp\"></span>\n </div>\n\n <div class=\"field\">\n <label>To</label>\n <span id=\"sentTo\" class=\"sent-value\"></span>\n </div>\n\n <div class=\"field\">\n <label>Message</label>\n <div id=\"sentBody\" class=\"sent-body\"></div>\n </div>\n\n <div class=\"actions\">\n <button id=\"sentCollapseButton\" type=\"button\" class=\"button button-secondary\">Collapse</button>\n </div>\n </div>\n </div>\n\n <script>\n (function () {\n 'use strict';\n\n var RESOURCE_URI = 'ui://microsoft-teams/compose-message';\n var currentEmail = '';\n // The host prefills the draft exactly once via ui/notifications/tool-result\n // on ready. We apply only the FIRST prefill and ignore any later ones, so a\n // stray or hostile re-post can't silently rewrite a draft the user has\n // already reviewed before clicking Send.\n var draftApplied = false;\n var pendingRequestId = null;\n var pendingPayload = null;\n var sending = false;\n var collapsed = false;\n var outcomeUnknown = false;\n var sent = false;\n var sendTimeoutId = null;\n // Just beyond the host-side tool-call timeout (60s) so a legitimately slow\n // send resolves to its real result first; this only fires when the reply\n // is genuinely lost, so the button can never stay stuck-silent.\n var SEND_TIMEOUT_MS = 75000;\n\n var composeForm = document.getElementById('composeForm');\n var collapsedState = document.getElementById('collapsedState');\n var collapsedMessage = document.getElementById('collapsedMessage');\n var reopenButton = document.getElementById('reopenButton');\n\n var sentView = document.getElementById('sentView');\n var sentTimestamp = document.getElementById('sentTimestamp');\n var sentFrom = document.getElementById('sentFrom');\n var sentTo = document.getElementById('sentTo');\n var sentSubject = document.getElementById('sentSubject');\n var sentBody = document.getElementById('sentBody');\n var sentCollapseButton = document.getElementById('sentCollapseButton');\n\n var toInput = document.getElementById('toInput');\n var subjectInput = document.getElementById('subjectInput');\n var bodyInput = document.getElementById('bodyInput');\n var fromValue = document.getElementById('fromValue');\n var fromHelper = document.getElementById('fromHelper');\n\n var sendButton = document.getElementById('sendButton');\n var sendLabel = document.getElementById('sendLabel');\n var sendSpinner = document.getElementById('sendSpinner');\n var cancelButton = document.getElementById('cancelButton');\n var retryButton = document.getElementById('retryButton');\n\n var errorBox = document.getElementById('errorBox');\n var errorText = document.getElementById('errorText');\n var successBox = document.getElementById('successBox');\n var unknownBox = document.getElementById('unknownBox');\n var unknownTitle = document.getElementById('unknownTitle');\n var unknownText = document.getElementById('unknownText');\n\n function applyThemeFromHostContext() {\n try {\n var context = window.__MCP_HOST_CONTEXT__;\n if (!context || (context.theme !== 'light' && context.theme !== 'dark')) {\n return;\n }\n document.documentElement.setAttribute('data-theme', context.theme);\n } catch (_) {\n // Ignore theme extraction errors\n }\n }\n\n function trimString(value) {\n return String(value || '').trim();\n }\n\n function applyFromValue(email) {\n var trimmed = trimString(email);\n if (trimmed) {\n fromValue.textContent = trimmed;\n fromValue.style.color = '';\n fromHelper.textContent = '';\n fromHelper.classList.add('hidden');\n } else {\n fromValue.textContent = 'Account not shown';\n fromValue.style.color = 'var(--muted)';\n fromHelper.textContent = '';\n fromHelper.classList.remove('hidden');\n }\n }\n\n function formatAddressItem(item) {\n if (item && typeof item === 'object') {\n var email = trimString(item.email || item.address || item.value);\n var name = trimString(item.name || item.displayName);\n if (email && name) {\n return name + ' <' + email + '>';\n }\n return email;\n }\n return trimString(item);\n }\n\n function normalizeAddressList(value) {\n if (Array.isArray(value)) {\n return value.map(formatAddressItem).filter(Boolean);\n }\n if (typeof value === 'string') {\n return value\n .split(',')\n .map(function (item) { return trimString(item); })\n .filter(Boolean);\n }\n return [];\n }\n\n function listToInputValue(list) {\n return normalizeAddressList(list).join(', ');\n }\n\n var resizeQueued = false;\n function postResize() {\n if (resizeQueued) return;\n resizeQueued = true;\n requestAnimationFrame(function () {\n resizeQueued = false;\n var height = Math.max(\n document.documentElement.scrollHeight,\n document.body ? document.body.scrollHeight : 0\n );\n window.parent.postMessage(\n { jsonrpc: '2.0', method: 'ui/resize', params: { height: height } },\n '*'\n );\n });\n }\n\n function postReady() {\n window.parent.postMessage(\n {\n method: 'mcp-app:ready',\n params: { resourceUri: RESOURCE_URI }\n },\n '*'\n );\n }\n\n // Exactly one of the three panels is visible at a time: the editable form,\n // the read-only sent view, or the collapsed summary bar.\n function showView(view) {\n composeForm.classList.toggle('hidden', view !== 'form');\n sentView.classList.toggle('hidden', view !== 'sent');\n collapsedState.classList.toggle('hidden', view !== 'collapsed');\n postResize();\n }\n\n function setCollapsed(nextCollapsed, message) {\n collapsed = nextCollapsed;\n if (nextCollapsed) {\n collapsedMessage.textContent = message || 'Draft collapsed.';\n showView('collapsed');\n } else {\n // Expanding after a send restores the read-only sent view (never the\n // editable form) so the user can re-read what went out without any risk\n // of a duplicate send; before sending, it restores the editable form.\n showView(sent ? 'sent' : 'form');\n }\n }\n\n function setSending(nextSending) {\n sending = nextSending;\n sendButton.disabled = nextSending;\n cancelButton.disabled = nextSending;\n toInput.disabled = nextSending;\n bodyInput.disabled = nextSending;\n sendSpinner.classList.toggle('hidden', !nextSending);\n sendLabel.textContent = nextSending ? 'Sending\u2026' : 'Send message';\n sendButton.setAttribute('aria-busy', nextSending ? 'true' : 'false');\n postResize();\n }\n\n function clearError() {\n errorText.textContent = '';\n errorBox.classList.add('hidden');\n }\n\n function showError(message) {\n errorText.textContent = message || 'Failed to send message.';\n errorBox.classList.remove('hidden');\n postResize();\n }\n\n function clearSuccess() {\n successBox.textContent = '';\n successBox.classList.add('hidden');\n }\n\n function showSuccess(message) {\n successBox.textContent = message;\n successBox.classList.remove('hidden');\n postResize();\n }\n\n function clearSendTimeout() {\n if (sendTimeoutId !== null) {\n clearTimeout(sendTimeoutId);\n sendTimeoutId = null;\n }\n }\n\n function clearUnknown() {\n outcomeUnknown = false;\n unknownTitle.textContent = '';\n unknownText.textContent = '';\n unknownBox.classList.add('hidden');\n }\n\n // No reply ever came back for the send (e.g. the host's response was lost\n // before reaching this iframe). We genuinely don't know whether the email\n // went out, so we must NOT claim success or failure: re-enable the form and\n // tell the user to verify before resending (the send is not idempotent, so\n // a blind retry could send a duplicate).\n function showSendUnknown() {\n outcomeUnknown = true;\n clearError();\n clearSuccess();\n unknownTitle.textContent = 'Not sure if this sent.';\n unknownText.textContent = \"Rebel didn't hear back in time. Check whether it sent before sending again, so it doesn't go out twice.\";\n unknownBox.classList.remove('hidden');\n postResize();\n }\n\n function handleSendTimeout() {\n sendTimeoutId = null;\n if (!pendingRequestId) {\n return;\n }\n pendingRequestId = null;\n setSending(false);\n showSendUnknown();\n }\n\n function readFormPayload() {\n var chatId = String(toInput.value || '').trim();\n var payload = {\n chatId: chatId,\n content: String(bodyInput.value || '')\n };\n return payload;\n }\n\n function validatePayload(payload) {\n if (!trimString(payload.chatId)) {\n return 'Add a chat in To.';\n }\n if (!trimString(payload.content)) {\n return 'Add a message before sending.';\n }\n return null;\n }\n\n function sendPayload(payload) {\n pendingPayload = payload;\n pendingRequestId = 'send-' + Date.now() + '-' + Math.random().toString(16).slice(2);\n setSending(true);\n clearError();\n clearSuccess();\n clearUnknown();\n clearSendTimeout();\n sendTimeoutId = setTimeout(handleSendTimeout, SEND_TIMEOUT_MS);\n\n window.parent.postMessage(\n {\n jsonrpc: '2.0',\n method: 'tools/call',\n params: {\n name: 'send_chat_message',\n arguments: payload\n },\n id: pendingRequestId\n },\n '*'\n );\n }\n\n function applyDraftData(rawDraft) {\n var draft = rawDraft && typeof rawDraft === 'object' ? rawDraft : {};\n toInput.value = String(draft.target || '');\n bodyInput.value = String(draft.text || '');\n clearError();\n clearSuccess();\n postResize();\n }\n\n function getDraftFromToolResult(payload) {\n if (!payload || typeof payload !== 'object') {\n return null;\n }\n // CANONICAL: post-A0 super-mcp hoists structuredContent onto the\n // outer block; the host posts it through `params.structuredContent`.\n if (payload.structuredContent && typeof payload.structuredContent === 'object') {\n return payload.structuredContent;\n }\n // MIGRATION SHIM: pre-A0 stored sessions replayed through the\n // current iframe lack `params.structuredContent` because the outer\n // block had no hoist at capture time. Read inner envelope text \u2192\n // JSON.parse \u2192 result.structuredContent. NOT regex-based.\n //\n // Lifetime: PERMANENT defensive read-side helper. Pre-A0 sessions\n // are persisted on disk and may be replayed indefinitely; there is\n // no time-based sunset. Removing this fallback would silently break\n // pre-A0 conversation replay.\n var text = typeof payload.text === 'string' ? payload.text :\n (Array.isArray(payload.content)\n ? (payload.content.find(function (c) { return c && c.type === 'text' && typeof c.text === 'string'; }) || {}).text\n : null);\n if (typeof text !== 'string') return null;\n try {\n var parsed = JSON.parse(text);\n if (parsed && typeof parsed === 'object' && parsed.result && typeof parsed.result === 'object') {\n var inner = parsed.result;\n if (inner.structuredContent && typeof inner.structuredContent === 'object') {\n return inner.structuredContent;\n }\n }\n } catch (_) {\n // Likely-envelope heuristic: text starts with `{` and contains\n // `\"package_id\"` in the first 64 chars. If JSON.parse failed on a\n // likely-envelope, surface a warning so the developer chasing\n // \"compose form is empty on replay\" sees a signal. For non-envelope\n // text (the dominant case), no log fires.\n var prefix = text.length > 64 ? text.slice(0, 64) : text;\n var likelyEnvelope = prefix.charAt(0) === '{' && prefix.indexOf('\"package_id\"') !== -1;\n if (likelyEnvelope && typeof console !== 'undefined' && typeof console.warn === 'function') {\n console.warn('[compose-email] Migration shim: JSON.parse failed on likely super-mcp envelope; pre-fill skipped');\n }\n }\n return null;\n }\n\n function findTextBlock(blocks) {\n if (!Array.isArray(blocks)) return null;\n var block = blocks.find(function (c) {\n return c && c.type === 'text' && typeof c.text === 'string';\n });\n return block ? block.text : null;\n }\n\n // Extract the send result identifiers ({ messageId, threadId, labelIds })\n // from the tool-call reply. The connector attaches structuredContent, which\n // super-mcp hoists onto the outer block, so the fast path is a single read.\n // The envelope-unwrapping fallback keeps this robust for hosts (or replayed\n // sessions) that forward only the serialized super-mcp text envelope.\n function getSendMetaFromResult(result) {\n if (!result || typeof result !== 'object') return null;\n if (result.structuredContent && typeof result.structuredContent === 'object') {\n return result.structuredContent;\n }\n try {\n var outerText = findTextBlock(result.content);\n if (typeof outerText !== 'string' && typeof result.text === 'string') {\n outerText = result.text;\n }\n if (typeof outerText !== 'string') return null;\n var envelope = JSON.parse(outerText);\n if (!envelope || typeof envelope !== 'object') return null;\n // outerText was the connector payload directly (no super-mcp envelope).\n if (envelope.messageId || envelope.threadId) return envelope;\n var inner = envelope.result;\n if (!inner || typeof inner !== 'object') return null;\n if (inner.structuredContent && typeof inner.structuredContent === 'object') {\n return inner.structuredContent;\n }\n var innerText = findTextBlock(inner.content);\n if (typeof innerText !== 'string') return null;\n var payload = JSON.parse(innerText);\n return (payload && typeof payload === 'object') ? payload : null;\n } catch (_) {\n return null;\n }\n }\n\n function formatSentTime() {\n try {\n return new Date().toLocaleString(undefined, {\n month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'\n });\n } catch (_) {\n return '';\n }\n }\n\n function renderSentView(payload, meta, stamp) {\n payload = payload && typeof payload === 'object' ? payload : {};\n sentTimestamp.textContent = stamp ? 'Sent ' + stamp : 'Sent';\n sentTo.textContent = String(payload.channel || payload.chatId || '');\n sentBody.textContent = String(payload.text || payload.content || '');\n }\n\n function handleSendRequest() {\n if (sending) return;\n\n var payload = readFormPayload();\n var validationError = validatePayload(payload);\n if (validationError) {\n showError(validationError);\n return;\n }\n\n sendPayload(payload);\n }\n\n // The Send button is type=\"button\" with an explicit click handler rather\n // than a form submit button. The Rebel host renders this iframe with a\n // sandbox of \"allow-scripts\" only \u2014 no \"allow-forms\" \u2014 so native form\n // submission is blocked by the browser BEFORE the submit event fires\n // (\"Blocked form submission ... the form's frame is sandboxed and the\n // allow-forms permission is not set\"). A submit-driven Send therefore\n // silently did nothing: no postMessage, no spinner, no telemetry.\n // A click handler needs no sandbox permission and is the same path\n // Cancel/Retry already use. The submit listener below stays only as a\n // defensive fallback for hosts that DO grant allow-forms; in this sandbox\n // it never fires (neither the button click nor Enter routes through it).\n // The sending guard keeps any double-fire (in an allow-forms host) a no-op.\n sendButton.addEventListener('click', function () {\n handleSendRequest();\n });\n\n composeForm.addEventListener('submit', function (event) {\n event.preventDefault();\n handleSendRequest();\n });\n\n retryButton.addEventListener('click', function () {\n if (sending) return;\n var payload = readFormPayload();\n var validationError = validatePayload(payload);\n if (validationError) {\n showError(validationError);\n return;\n }\n sendPayload(payload);\n });\n\n cancelButton.addEventListener('click', function () {\n if (sending) return;\n // After a lost-reply timeout we don't know if the email sent, so don't\n // claim \"Nothing sent.\" on collapse \u2014 point the user to verify instead.\n var collapseMessage = outcomeUnknown\n ? 'Collapsed. Check whether it sent if you are not sure.'\n : 'Draft collapsed. Nothing sent.';\n clearError();\n clearSuccess();\n clearUnknown();\n setCollapsed(true, collapseMessage);\n });\n\n reopenButton.addEventListener('click', function () {\n setCollapsed(false);\n });\n\n sentCollapseButton.addEventListener('click', function () {\n setCollapsed(true, collapsedMessage.textContent || 'Message sent.');\n });\n\n [toInput, bodyInput].forEach(function (element) {\n element.addEventListener('input', function () {\n clearError();\n clearSuccess();\n clearUnknown();\n postResize();\n });\n });\n\n window.addEventListener('message', function (event) {\n // Honest scope (origin-independent): the iframe sandbox origin is 'null',\n // so validating event.origin is unreliable across dev/prod/custom-scheme\n // hosts. Instead reject any *identified* sender that isn't our host frame\n // (window.parent). A real cross-frame postMessage always stamps\n // event.source with the sending window, so a hostile sibling frame is\n // rejected here; a null source only occurs for same-context synthetic\n // dispatch (our own code) and cannot be forged from outside, so we\n // tolerate it. This does not authenticate the host's identity \u2014 the host\n // separately authenticates this iframe by origin+source.\n if (event.source && event.source !== window.parent) {\n return;\n }\n\n var data = event.data;\n if (!data || typeof data !== 'object') {\n return;\n }\n\n if (data.method === 'ui/notifications/tool-result') {\n // Always parse (so the migration-shim's malformed-envelope warning\n // still fires), but apply only the first successful prefill: a stray\n // or hostile re-post can't silently rewrite a draft the user has\n // already reviewed.\n var draft = getDraftFromToolResult(data.params);\n if (draft && !draftApplied) {\n draftApplied = true;\n applyDraftData(draft);\n }\n return;\n }\n\n if (data.jsonrpc !== '2.0' || !pendingRequestId || data.id !== pendingRequestId) {\n return;\n }\n\n pendingRequestId = null;\n clearSendTimeout();\n clearUnknown();\n setSending(false);\n\n if (data.error) {\n var errorMessage = data.error && typeof data.error.message === 'string'\n ? data.error.message\n : 'Failed to send message.';\n showError(errorMessage);\n return;\n }\n\n clearError();\n clearSuccess();\n // Preserve what was sent instead of discarding the draft. pendingPayload\n // is the exact payload we posted. We default to the collapsed summary, but\n // Reopen now restores a read-only view of the sent message.\n var sentPayload = pendingPayload || readFormPayload();\n var stamp = formatSentTime();\n renderSentView(sentPayload, getSendMetaFromResult(data.result), stamp);\n sent = true;\n setCollapsed(true, stamp ? 'Message sent \u00B7 ' + stamp : 'Message sent.');\n });\n\n if (typeof ResizeObserver === 'function' && document.body) {\n var observer = new ResizeObserver(postResize);\n observer.observe(document.body);\n }\n\n applyThemeFromHostContext();\n setCollapsed(false);\n postResize();\n postReady();\n\n window.parent.postMessage(\n {\n jsonrpc: '2.0',\n method: 'ui/initialize',\n params: { resourceUri: RESOURCE_URI },\n id: 'compose-email-init'\n },\n '*'\n );\n })();\n </script>\n</body>\n</html>\n";
|
|
2
|
+
//# sourceMappingURL=compose-message-template.d.ts.map
|
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-compose-html.mjs — do not edit by hand.
|
|
2
|
+
// Regenerate with "npm run compose:template"; "npm test" runs the --check drift gate.
|
|
3
|
+
// The generator also writes compose-message.html, keeping the two in sync.
|
|
4
|
+
export const COMPOSE_MESSAGE_HTML = `
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8">
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10
|
+
<title>Compose message</title>
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--bg: #ffffff;
|
|
14
|
+
--panel: #ffffff;
|
|
15
|
+
--border: #d9dde5;
|
|
16
|
+
--text: #1f2937;
|
|
17
|
+
--muted: #6b7280;
|
|
18
|
+
--input-bg: #ffffff;
|
|
19
|
+
--button-bg: #1f6feb;
|
|
20
|
+
--button-text: #ffffff;
|
|
21
|
+
--button-bg-hover: #1b63d3;
|
|
22
|
+
--secondary-bg: #f3f4f6;
|
|
23
|
+
--secondary-text: #1f2937;
|
|
24
|
+
--secondary-border: #d1d5db;
|
|
25
|
+
--error-bg: #fef2f2;
|
|
26
|
+
--error-border: #fecaca;
|
|
27
|
+
--error-text: #991b1b;
|
|
28
|
+
--success-bg: #ecfdf5;
|
|
29
|
+
--success-border: #bbf7d0;
|
|
30
|
+
--success-text: #166534;
|
|
31
|
+
--warning-bg: #fffbeb;
|
|
32
|
+
--warning-border: #fde68a;
|
|
33
|
+
--warning-text: #92400e;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@media (prefers-color-scheme: dark) {
|
|
37
|
+
:root {
|
|
38
|
+
--bg: #0f172a;
|
|
39
|
+
--panel: #111827;
|
|
40
|
+
--border: #334155;
|
|
41
|
+
--text: #e5e7eb;
|
|
42
|
+
--muted: #9ca3af;
|
|
43
|
+
--input-bg: #0b1220;
|
|
44
|
+
--button-bg: #3b82f6;
|
|
45
|
+
--button-text: #eff6ff;
|
|
46
|
+
--button-bg-hover: #2563eb;
|
|
47
|
+
--secondary-bg: #1f2937;
|
|
48
|
+
--secondary-text: #e5e7eb;
|
|
49
|
+
--secondary-border: #374151;
|
|
50
|
+
--error-bg: #2b1011;
|
|
51
|
+
--error-border: #7f1d1d;
|
|
52
|
+
--error-text: #fca5a5;
|
|
53
|
+
--success-bg: #052e1b;
|
|
54
|
+
--success-border: #166534;
|
|
55
|
+
--success-text: #86efac;
|
|
56
|
+
--warning-bg: #2a2106;
|
|
57
|
+
--warning-border: #854d0e;
|
|
58
|
+
--warning-text: #fcd34d;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
html[data-theme="light"] {
|
|
63
|
+
color-scheme: light;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
html[data-theme="dark"] {
|
|
67
|
+
color-scheme: dark;
|
|
68
|
+
--bg: #0f172a;
|
|
69
|
+
--panel: #111827;
|
|
70
|
+
--border: #334155;
|
|
71
|
+
--text: #e5e7eb;
|
|
72
|
+
--muted: #9ca3af;
|
|
73
|
+
--input-bg: #0b1220;
|
|
74
|
+
--button-bg: #3b82f6;
|
|
75
|
+
--button-text: #eff6ff;
|
|
76
|
+
--button-bg-hover: #2563eb;
|
|
77
|
+
--secondary-bg: #1f2937;
|
|
78
|
+
--secondary-text: #e5e7eb;
|
|
79
|
+
--secondary-border: #374151;
|
|
80
|
+
--error-bg: #2b1011;
|
|
81
|
+
--error-border: #7f1d1d;
|
|
82
|
+
--error-text: #fca5a5;
|
|
83
|
+
--success-bg: #052e1b;
|
|
84
|
+
--success-border: #166534;
|
|
85
|
+
--success-text: #86efac;
|
|
86
|
+
--warning-bg: #2a2106;
|
|
87
|
+
--warning-border: #854d0e;
|
|
88
|
+
--warning-text: #fcd34d;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
* {
|
|
92
|
+
box-sizing: border-box;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
body {
|
|
96
|
+
margin: 0;
|
|
97
|
+
padding: 16px;
|
|
98
|
+
background: var(--bg);
|
|
99
|
+
color: var(--text);
|
|
100
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
101
|
+
line-height: 1.4;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.wrapper {
|
|
105
|
+
width: 100%;
|
|
106
|
+
max-width: 720px;
|
|
107
|
+
margin: 0 auto;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.card {
|
|
111
|
+
background: var(--panel);
|
|
112
|
+
border: 1px solid var(--border);
|
|
113
|
+
border-radius: 12px;
|
|
114
|
+
padding: 14px;
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
gap: 12px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.field {
|
|
121
|
+
display: flex;
|
|
122
|
+
flex-direction: column;
|
|
123
|
+
gap: 6px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.field label {
|
|
127
|
+
font-size: 12px;
|
|
128
|
+
font-weight: 600;
|
|
129
|
+
color: var(--muted);
|
|
130
|
+
text-transform: uppercase;
|
|
131
|
+
letter-spacing: 0.04em;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.from-value {
|
|
135
|
+
color: var(--text);
|
|
136
|
+
font-size: 14px;
|
|
137
|
+
padding: 2px 0;
|
|
138
|
+
word-break: break-word;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.from-helper {
|
|
142
|
+
font-size: 12px;
|
|
143
|
+
color: var(--muted);
|
|
144
|
+
margin-top: 2px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.input,
|
|
148
|
+
.textarea {
|
|
149
|
+
width: 100%;
|
|
150
|
+
background: var(--input-bg);
|
|
151
|
+
color: var(--text);
|
|
152
|
+
border: 1px solid var(--border);
|
|
153
|
+
border-radius: 8px;
|
|
154
|
+
padding: 10px 12px;
|
|
155
|
+
font-size: 14px;
|
|
156
|
+
font-family: inherit;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.input:focus,
|
|
160
|
+
.textarea:focus {
|
|
161
|
+
outline: 2px solid rgba(59, 130, 246, 0.35);
|
|
162
|
+
outline-offset: 1px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.textarea {
|
|
166
|
+
min-height: 180px;
|
|
167
|
+
resize: vertical;
|
|
168
|
+
white-space: pre-wrap;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.toggles {
|
|
172
|
+
display: flex;
|
|
173
|
+
gap: 12px;
|
|
174
|
+
align-items: center;
|
|
175
|
+
flex-wrap: wrap;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.link-button {
|
|
179
|
+
border: none;
|
|
180
|
+
padding: 0;
|
|
181
|
+
background: none;
|
|
182
|
+
color: var(--muted);
|
|
183
|
+
text-decoration: underline;
|
|
184
|
+
font-size: 12px;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.link-button:hover {
|
|
189
|
+
color: var(--text);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.actions {
|
|
193
|
+
display: flex;
|
|
194
|
+
justify-content: flex-end;
|
|
195
|
+
gap: 10px;
|
|
196
|
+
margin-top: 2px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.button {
|
|
200
|
+
border-radius: 8px;
|
|
201
|
+
border: 1px solid transparent;
|
|
202
|
+
min-height: 36px;
|
|
203
|
+
padding: 0 14px;
|
|
204
|
+
font-size: 13px;
|
|
205
|
+
font-weight: 600;
|
|
206
|
+
font-family: inherit;
|
|
207
|
+
cursor: pointer;
|
|
208
|
+
display: inline-flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
justify-content: center;
|
|
211
|
+
gap: 8px;
|
|
212
|
+
transition: background 120ms ease, opacity 120ms ease, border-color 120ms ease;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.button:disabled {
|
|
216
|
+
cursor: not-allowed;
|
|
217
|
+
opacity: 0.75;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.button-primary {
|
|
221
|
+
background: var(--button-bg);
|
|
222
|
+
color: var(--button-text);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.button-primary:hover:enabled {
|
|
226
|
+
background: var(--button-bg-hover);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.button-secondary {
|
|
230
|
+
background: var(--secondary-bg);
|
|
231
|
+
color: var(--secondary-text);
|
|
232
|
+
border-color: var(--secondary-border);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.spinner {
|
|
236
|
+
width: 14px;
|
|
237
|
+
height: 14px;
|
|
238
|
+
border: 2px solid currentColor;
|
|
239
|
+
border-right-color: transparent;
|
|
240
|
+
border-radius: 50%;
|
|
241
|
+
display: inline-block;
|
|
242
|
+
animation: spin 700ms linear infinite;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@keyframes spin {
|
|
246
|
+
to { transform: rotate(360deg); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.status {
|
|
250
|
+
border-radius: 8px;
|
|
251
|
+
border: 1px solid transparent;
|
|
252
|
+
padding: 8px 10px;
|
|
253
|
+
font-size: 13px;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.error {
|
|
257
|
+
background: var(--error-bg);
|
|
258
|
+
border-color: var(--error-border);
|
|
259
|
+
color: var(--error-text);
|
|
260
|
+
display: flex;
|
|
261
|
+
justify-content: space-between;
|
|
262
|
+
align-items: center;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
flex-wrap: wrap;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.success {
|
|
268
|
+
background: var(--success-bg);
|
|
269
|
+
border-color: var(--success-border);
|
|
270
|
+
color: var(--success-text);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.warning {
|
|
274
|
+
background: var(--warning-bg);
|
|
275
|
+
border-color: var(--warning-border);
|
|
276
|
+
color: var(--warning-text);
|
|
277
|
+
display: block;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.warning strong {
|
|
281
|
+
display: block;
|
|
282
|
+
margin-bottom: 2px;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.warning .warning-detail {
|
|
286
|
+
opacity: 0.85;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.collapsed {
|
|
290
|
+
border: 1px solid var(--border);
|
|
291
|
+
border-radius: 10px;
|
|
292
|
+
background: var(--panel);
|
|
293
|
+
padding: 10px 12px;
|
|
294
|
+
display: flex;
|
|
295
|
+
justify-content: space-between;
|
|
296
|
+
align-items: center;
|
|
297
|
+
gap: 8px;
|
|
298
|
+
font-size: 13px;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.hidden {
|
|
302
|
+
display: none !important;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.sent-header {
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
gap: 8px;
|
|
309
|
+
flex-wrap: wrap;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.sent-badge {
|
|
313
|
+
display: inline-flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
background: var(--success-bg);
|
|
316
|
+
color: var(--success-text);
|
|
317
|
+
border: 1px solid var(--success-border);
|
|
318
|
+
border-radius: 999px;
|
|
319
|
+
padding: 2px 10px;
|
|
320
|
+
font-size: 12px;
|
|
321
|
+
font-weight: 600;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.sent-timestamp {
|
|
325
|
+
color: var(--muted);
|
|
326
|
+
font-size: 12px;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.sent-value {
|
|
330
|
+
color: var(--text);
|
|
331
|
+
font-size: 14px;
|
|
332
|
+
word-break: break-word;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.sent-body {
|
|
336
|
+
color: var(--text);
|
|
337
|
+
font-size: 14px;
|
|
338
|
+
white-space: pre-wrap;
|
|
339
|
+
word-break: break-word;
|
|
340
|
+
max-height: 260px;
|
|
341
|
+
overflow: auto;
|
|
342
|
+
border: 1px solid var(--border);
|
|
343
|
+
border-radius: 8px;
|
|
344
|
+
padding: 10px 12px;
|
|
345
|
+
background: var(--input-bg);
|
|
346
|
+
}
|
|
347
|
+
</style>
|
|
348
|
+
</head>
|
|
349
|
+
<body>
|
|
350
|
+
<div class="wrapper">
|
|
351
|
+
<div id="collapsedState" class="collapsed hidden">
|
|
352
|
+
<span id="collapsedMessage">Draft collapsed.</span>
|
|
353
|
+
<button id="reopenButton" type="button" class="button button-secondary">Reopen</button>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<form id="composeForm" class="card" novalidate aria-label="Compose message">
|
|
357
|
+
<div class="field">
|
|
358
|
+
<span id="workspaceLine" class="from-helper hidden"></span>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div class="field">
|
|
362
|
+
<label for="toInput">To</label>
|
|
363
|
+
<input id="toInput" class="input" type="text" autocomplete="off" placeholder="Chat ID">
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div class="field">
|
|
367
|
+
<label for="bodyInput">Message</label>
|
|
368
|
+
<textarea id="bodyInput" class="textarea" placeholder="Write your message"></textarea>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<div id="errorBox" class="status error hidden" role="alert">
|
|
372
|
+
<span id="errorText"></span>
|
|
373
|
+
<button id="retryButton" type="button" class="link-button">Retry</button>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<div id="successBox" class="status success hidden" role="status" aria-live="polite"></div>
|
|
377
|
+
|
|
378
|
+
<div id="unknownBox" class="status warning hidden" role="status" aria-live="polite">
|
|
379
|
+
<strong id="unknownTitle"></strong>
|
|
380
|
+
<span id="unknownText" class="warning-detail"></span>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<div class="actions">
|
|
384
|
+
<button id="cancelButton" type="button" class="button button-secondary">Cancel</button>
|
|
385
|
+
<button id="sendButton" type="button" class="button button-primary">
|
|
386
|
+
<span id="sendSpinner" class="spinner hidden" aria-hidden="true"></span>
|
|
387
|
+
<span id="sendLabel">Send message</span>
|
|
388
|
+
</button>
|
|
389
|
+
</div>
|
|
390
|
+
</form>
|
|
391
|
+
|
|
392
|
+
<div id="sentView" class="card hidden" role="status" aria-live="polite">
|
|
393
|
+
<div class="sent-header">
|
|
394
|
+
<span class="sent-badge">Sent</span>
|
|
395
|
+
<span id="sentTimestamp" class="sent-timestamp"></span>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div class="field">
|
|
399
|
+
<label>To</label>
|
|
400
|
+
<span id="sentTo" class="sent-value"></span>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
<div class="field">
|
|
404
|
+
<label>Message</label>
|
|
405
|
+
<div id="sentBody" class="sent-body"></div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<div class="actions">
|
|
409
|
+
<button id="sentCollapseButton" type="button" class="button button-secondary">Collapse</button>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<script>
|
|
415
|
+
(function () {
|
|
416
|
+
'use strict';
|
|
417
|
+
|
|
418
|
+
var RESOURCE_URI = 'ui://microsoft-teams/compose-message';
|
|
419
|
+
var currentEmail = '';
|
|
420
|
+
// The host prefills the draft exactly once via ui/notifications/tool-result
|
|
421
|
+
// on ready. We apply only the FIRST prefill and ignore any later ones, so a
|
|
422
|
+
// stray or hostile re-post can't silently rewrite a draft the user has
|
|
423
|
+
// already reviewed before clicking Send.
|
|
424
|
+
var draftApplied = false;
|
|
425
|
+
var pendingRequestId = null;
|
|
426
|
+
var pendingPayload = null;
|
|
427
|
+
var sending = false;
|
|
428
|
+
var collapsed = false;
|
|
429
|
+
var outcomeUnknown = false;
|
|
430
|
+
var sent = false;
|
|
431
|
+
var sendTimeoutId = null;
|
|
432
|
+
// Just beyond the host-side tool-call timeout (60s) so a legitimately slow
|
|
433
|
+
// send resolves to its real result first; this only fires when the reply
|
|
434
|
+
// is genuinely lost, so the button can never stay stuck-silent.
|
|
435
|
+
var SEND_TIMEOUT_MS = 75000;
|
|
436
|
+
|
|
437
|
+
var composeForm = document.getElementById('composeForm');
|
|
438
|
+
var collapsedState = document.getElementById('collapsedState');
|
|
439
|
+
var collapsedMessage = document.getElementById('collapsedMessage');
|
|
440
|
+
var reopenButton = document.getElementById('reopenButton');
|
|
441
|
+
|
|
442
|
+
var sentView = document.getElementById('sentView');
|
|
443
|
+
var sentTimestamp = document.getElementById('sentTimestamp');
|
|
444
|
+
var sentFrom = document.getElementById('sentFrom');
|
|
445
|
+
var sentTo = document.getElementById('sentTo');
|
|
446
|
+
var sentSubject = document.getElementById('sentSubject');
|
|
447
|
+
var sentBody = document.getElementById('sentBody');
|
|
448
|
+
var sentCollapseButton = document.getElementById('sentCollapseButton');
|
|
449
|
+
|
|
450
|
+
var toInput = document.getElementById('toInput');
|
|
451
|
+
var subjectInput = document.getElementById('subjectInput');
|
|
452
|
+
var bodyInput = document.getElementById('bodyInput');
|
|
453
|
+
var fromValue = document.getElementById('fromValue');
|
|
454
|
+
var fromHelper = document.getElementById('fromHelper');
|
|
455
|
+
|
|
456
|
+
var sendButton = document.getElementById('sendButton');
|
|
457
|
+
var sendLabel = document.getElementById('sendLabel');
|
|
458
|
+
var sendSpinner = document.getElementById('sendSpinner');
|
|
459
|
+
var cancelButton = document.getElementById('cancelButton');
|
|
460
|
+
var retryButton = document.getElementById('retryButton');
|
|
461
|
+
|
|
462
|
+
var errorBox = document.getElementById('errorBox');
|
|
463
|
+
var errorText = document.getElementById('errorText');
|
|
464
|
+
var successBox = document.getElementById('successBox');
|
|
465
|
+
var unknownBox = document.getElementById('unknownBox');
|
|
466
|
+
var unknownTitle = document.getElementById('unknownTitle');
|
|
467
|
+
var unknownText = document.getElementById('unknownText');
|
|
468
|
+
|
|
469
|
+
function applyThemeFromHostContext() {
|
|
470
|
+
try {
|
|
471
|
+
var context = window.__MCP_HOST_CONTEXT__;
|
|
472
|
+
if (!context || (context.theme !== 'light' && context.theme !== 'dark')) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
document.documentElement.setAttribute('data-theme', context.theme);
|
|
476
|
+
} catch (_) {
|
|
477
|
+
// Ignore theme extraction errors
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function trimString(value) {
|
|
482
|
+
return String(value || '').trim();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function applyFromValue(email) {
|
|
486
|
+
var trimmed = trimString(email);
|
|
487
|
+
if (trimmed) {
|
|
488
|
+
fromValue.textContent = trimmed;
|
|
489
|
+
fromValue.style.color = '';
|
|
490
|
+
fromHelper.textContent = '';
|
|
491
|
+
fromHelper.classList.add('hidden');
|
|
492
|
+
} else {
|
|
493
|
+
fromValue.textContent = 'Account not shown';
|
|
494
|
+
fromValue.style.color = 'var(--muted)';
|
|
495
|
+
fromHelper.textContent = '';
|
|
496
|
+
fromHelper.classList.remove('hidden');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function formatAddressItem(item) {
|
|
501
|
+
if (item && typeof item === 'object') {
|
|
502
|
+
var email = trimString(item.email || item.address || item.value);
|
|
503
|
+
var name = trimString(item.name || item.displayName);
|
|
504
|
+
if (email && name) {
|
|
505
|
+
return name + ' <' + email + '>';
|
|
506
|
+
}
|
|
507
|
+
return email;
|
|
508
|
+
}
|
|
509
|
+
return trimString(item);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function normalizeAddressList(value) {
|
|
513
|
+
if (Array.isArray(value)) {
|
|
514
|
+
return value.map(formatAddressItem).filter(Boolean);
|
|
515
|
+
}
|
|
516
|
+
if (typeof value === 'string') {
|
|
517
|
+
return value
|
|
518
|
+
.split(',')
|
|
519
|
+
.map(function (item) { return trimString(item); })
|
|
520
|
+
.filter(Boolean);
|
|
521
|
+
}
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function listToInputValue(list) {
|
|
526
|
+
return normalizeAddressList(list).join(', ');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
var resizeQueued = false;
|
|
530
|
+
function postResize() {
|
|
531
|
+
if (resizeQueued) return;
|
|
532
|
+
resizeQueued = true;
|
|
533
|
+
requestAnimationFrame(function () {
|
|
534
|
+
resizeQueued = false;
|
|
535
|
+
var height = Math.max(
|
|
536
|
+
document.documentElement.scrollHeight,
|
|
537
|
+
document.body ? document.body.scrollHeight : 0
|
|
538
|
+
);
|
|
539
|
+
window.parent.postMessage(
|
|
540
|
+
{ jsonrpc: '2.0', method: 'ui/resize', params: { height: height } },
|
|
541
|
+
'*'
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function postReady() {
|
|
547
|
+
window.parent.postMessage(
|
|
548
|
+
{
|
|
549
|
+
method: 'mcp-app:ready',
|
|
550
|
+
params: { resourceUri: RESOURCE_URI }
|
|
551
|
+
},
|
|
552
|
+
'*'
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Exactly one of the three panels is visible at a time: the editable form,
|
|
557
|
+
// the read-only sent view, or the collapsed summary bar.
|
|
558
|
+
function showView(view) {
|
|
559
|
+
composeForm.classList.toggle('hidden', view !== 'form');
|
|
560
|
+
sentView.classList.toggle('hidden', view !== 'sent');
|
|
561
|
+
collapsedState.classList.toggle('hidden', view !== 'collapsed');
|
|
562
|
+
postResize();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function setCollapsed(nextCollapsed, message) {
|
|
566
|
+
collapsed = nextCollapsed;
|
|
567
|
+
if (nextCollapsed) {
|
|
568
|
+
collapsedMessage.textContent = message || 'Draft collapsed.';
|
|
569
|
+
showView('collapsed');
|
|
570
|
+
} else {
|
|
571
|
+
// Expanding after a send restores the read-only sent view (never the
|
|
572
|
+
// editable form) so the user can re-read what went out without any risk
|
|
573
|
+
// of a duplicate send; before sending, it restores the editable form.
|
|
574
|
+
showView(sent ? 'sent' : 'form');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function setSending(nextSending) {
|
|
579
|
+
sending = nextSending;
|
|
580
|
+
sendButton.disabled = nextSending;
|
|
581
|
+
cancelButton.disabled = nextSending;
|
|
582
|
+
toInput.disabled = nextSending;
|
|
583
|
+
bodyInput.disabled = nextSending;
|
|
584
|
+
sendSpinner.classList.toggle('hidden', !nextSending);
|
|
585
|
+
sendLabel.textContent = nextSending ? 'Sending…' : 'Send message';
|
|
586
|
+
sendButton.setAttribute('aria-busy', nextSending ? 'true' : 'false');
|
|
587
|
+
postResize();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function clearError() {
|
|
591
|
+
errorText.textContent = '';
|
|
592
|
+
errorBox.classList.add('hidden');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function showError(message) {
|
|
596
|
+
errorText.textContent = message || 'Failed to send message.';
|
|
597
|
+
errorBox.classList.remove('hidden');
|
|
598
|
+
postResize();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function clearSuccess() {
|
|
602
|
+
successBox.textContent = '';
|
|
603
|
+
successBox.classList.add('hidden');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function showSuccess(message) {
|
|
607
|
+
successBox.textContent = message;
|
|
608
|
+
successBox.classList.remove('hidden');
|
|
609
|
+
postResize();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function clearSendTimeout() {
|
|
613
|
+
if (sendTimeoutId !== null) {
|
|
614
|
+
clearTimeout(sendTimeoutId);
|
|
615
|
+
sendTimeoutId = null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function clearUnknown() {
|
|
620
|
+
outcomeUnknown = false;
|
|
621
|
+
unknownTitle.textContent = '';
|
|
622
|
+
unknownText.textContent = '';
|
|
623
|
+
unknownBox.classList.add('hidden');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// No reply ever came back for the send (e.g. the host's response was lost
|
|
627
|
+
// before reaching this iframe). We genuinely don't know whether the email
|
|
628
|
+
// went out, so we must NOT claim success or failure: re-enable the form and
|
|
629
|
+
// tell the user to verify before resending (the send is not idempotent, so
|
|
630
|
+
// a blind retry could send a duplicate).
|
|
631
|
+
function showSendUnknown() {
|
|
632
|
+
outcomeUnknown = true;
|
|
633
|
+
clearError();
|
|
634
|
+
clearSuccess();
|
|
635
|
+
unknownTitle.textContent = 'Not sure if this sent.';
|
|
636
|
+
unknownText.textContent = "Rebel didn't hear back in time. Check whether it sent before sending again, so it doesn't go out twice.";
|
|
637
|
+
unknownBox.classList.remove('hidden');
|
|
638
|
+
postResize();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function handleSendTimeout() {
|
|
642
|
+
sendTimeoutId = null;
|
|
643
|
+
if (!pendingRequestId) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
pendingRequestId = null;
|
|
647
|
+
setSending(false);
|
|
648
|
+
showSendUnknown();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function readFormPayload() {
|
|
652
|
+
var chatId = String(toInput.value || '').trim();
|
|
653
|
+
var payload = {
|
|
654
|
+
chatId: chatId,
|
|
655
|
+
content: String(bodyInput.value || '')
|
|
656
|
+
};
|
|
657
|
+
return payload;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function validatePayload(payload) {
|
|
661
|
+
if (!trimString(payload.chatId)) {
|
|
662
|
+
return 'Add a chat in To.';
|
|
663
|
+
}
|
|
664
|
+
if (!trimString(payload.content)) {
|
|
665
|
+
return 'Add a message before sending.';
|
|
666
|
+
}
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function sendPayload(payload) {
|
|
671
|
+
pendingPayload = payload;
|
|
672
|
+
pendingRequestId = 'send-' + Date.now() + '-' + Math.random().toString(16).slice(2);
|
|
673
|
+
setSending(true);
|
|
674
|
+
clearError();
|
|
675
|
+
clearSuccess();
|
|
676
|
+
clearUnknown();
|
|
677
|
+
clearSendTimeout();
|
|
678
|
+
sendTimeoutId = setTimeout(handleSendTimeout, SEND_TIMEOUT_MS);
|
|
679
|
+
|
|
680
|
+
window.parent.postMessage(
|
|
681
|
+
{
|
|
682
|
+
jsonrpc: '2.0',
|
|
683
|
+
method: 'tools/call',
|
|
684
|
+
params: {
|
|
685
|
+
name: 'send_chat_message',
|
|
686
|
+
arguments: payload
|
|
687
|
+
},
|
|
688
|
+
id: pendingRequestId
|
|
689
|
+
},
|
|
690
|
+
'*'
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function applyDraftData(rawDraft) {
|
|
695
|
+
var draft = rawDraft && typeof rawDraft === 'object' ? rawDraft : {};
|
|
696
|
+
toInput.value = String(draft.target || '');
|
|
697
|
+
bodyInput.value = String(draft.text || '');
|
|
698
|
+
clearError();
|
|
699
|
+
clearSuccess();
|
|
700
|
+
postResize();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function getDraftFromToolResult(payload) {
|
|
704
|
+
if (!payload || typeof payload !== 'object') {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
// CANONICAL: post-A0 super-mcp hoists structuredContent onto the
|
|
708
|
+
// outer block; the host posts it through \`params.structuredContent\`.
|
|
709
|
+
if (payload.structuredContent && typeof payload.structuredContent === 'object') {
|
|
710
|
+
return payload.structuredContent;
|
|
711
|
+
}
|
|
712
|
+
// MIGRATION SHIM: pre-A0 stored sessions replayed through the
|
|
713
|
+
// current iframe lack \`params.structuredContent\` because the outer
|
|
714
|
+
// block had no hoist at capture time. Read inner envelope text →
|
|
715
|
+
// JSON.parse → result.structuredContent. NOT regex-based.
|
|
716
|
+
//
|
|
717
|
+
// Lifetime: PERMANENT defensive read-side helper. Pre-A0 sessions
|
|
718
|
+
// are persisted on disk and may be replayed indefinitely; there is
|
|
719
|
+
// no time-based sunset. Removing this fallback would silently break
|
|
720
|
+
// pre-A0 conversation replay.
|
|
721
|
+
var text = typeof payload.text === 'string' ? payload.text :
|
|
722
|
+
(Array.isArray(payload.content)
|
|
723
|
+
? (payload.content.find(function (c) { return c && c.type === 'text' && typeof c.text === 'string'; }) || {}).text
|
|
724
|
+
: null);
|
|
725
|
+
if (typeof text !== 'string') return null;
|
|
726
|
+
try {
|
|
727
|
+
var parsed = JSON.parse(text);
|
|
728
|
+
if (parsed && typeof parsed === 'object' && parsed.result && typeof parsed.result === 'object') {
|
|
729
|
+
var inner = parsed.result;
|
|
730
|
+
if (inner.structuredContent && typeof inner.structuredContent === 'object') {
|
|
731
|
+
return inner.structuredContent;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
} catch (_) {
|
|
735
|
+
// Likely-envelope heuristic: text starts with \`{\` and contains
|
|
736
|
+
// \`"package_id"\` in the first 64 chars. If JSON.parse failed on a
|
|
737
|
+
// likely-envelope, surface a warning so the developer chasing
|
|
738
|
+
// "compose form is empty on replay" sees a signal. For non-envelope
|
|
739
|
+
// text (the dominant case), no log fires.
|
|
740
|
+
var prefix = text.length > 64 ? text.slice(0, 64) : text;
|
|
741
|
+
var likelyEnvelope = prefix.charAt(0) === '{' && prefix.indexOf('"package_id"') !== -1;
|
|
742
|
+
if (likelyEnvelope && typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
743
|
+
console.warn('[compose-email] Migration shim: JSON.parse failed on likely super-mcp envelope; pre-fill skipped');
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function findTextBlock(blocks) {
|
|
750
|
+
if (!Array.isArray(blocks)) return null;
|
|
751
|
+
var block = blocks.find(function (c) {
|
|
752
|
+
return c && c.type === 'text' && typeof c.text === 'string';
|
|
753
|
+
});
|
|
754
|
+
return block ? block.text : null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Extract the send result identifiers ({ messageId, threadId, labelIds })
|
|
758
|
+
// from the tool-call reply. The connector attaches structuredContent, which
|
|
759
|
+
// super-mcp hoists onto the outer block, so the fast path is a single read.
|
|
760
|
+
// The envelope-unwrapping fallback keeps this robust for hosts (or replayed
|
|
761
|
+
// sessions) that forward only the serialized super-mcp text envelope.
|
|
762
|
+
function getSendMetaFromResult(result) {
|
|
763
|
+
if (!result || typeof result !== 'object') return null;
|
|
764
|
+
if (result.structuredContent && typeof result.structuredContent === 'object') {
|
|
765
|
+
return result.structuredContent;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
var outerText = findTextBlock(result.content);
|
|
769
|
+
if (typeof outerText !== 'string' && typeof result.text === 'string') {
|
|
770
|
+
outerText = result.text;
|
|
771
|
+
}
|
|
772
|
+
if (typeof outerText !== 'string') return null;
|
|
773
|
+
var envelope = JSON.parse(outerText);
|
|
774
|
+
if (!envelope || typeof envelope !== 'object') return null;
|
|
775
|
+
// outerText was the connector payload directly (no super-mcp envelope).
|
|
776
|
+
if (envelope.messageId || envelope.threadId) return envelope;
|
|
777
|
+
var inner = envelope.result;
|
|
778
|
+
if (!inner || typeof inner !== 'object') return null;
|
|
779
|
+
if (inner.structuredContent && typeof inner.structuredContent === 'object') {
|
|
780
|
+
return inner.structuredContent;
|
|
781
|
+
}
|
|
782
|
+
var innerText = findTextBlock(inner.content);
|
|
783
|
+
if (typeof innerText !== 'string') return null;
|
|
784
|
+
var payload = JSON.parse(innerText);
|
|
785
|
+
return (payload && typeof payload === 'object') ? payload : null;
|
|
786
|
+
} catch (_) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function formatSentTime() {
|
|
792
|
+
try {
|
|
793
|
+
return new Date().toLocaleString(undefined, {
|
|
794
|
+
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
|
795
|
+
});
|
|
796
|
+
} catch (_) {
|
|
797
|
+
return '';
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function renderSentView(payload, meta, stamp) {
|
|
802
|
+
payload = payload && typeof payload === 'object' ? payload : {};
|
|
803
|
+
sentTimestamp.textContent = stamp ? 'Sent ' + stamp : 'Sent';
|
|
804
|
+
sentTo.textContent = String(payload.channel || payload.chatId || '');
|
|
805
|
+
sentBody.textContent = String(payload.text || payload.content || '');
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function handleSendRequest() {
|
|
809
|
+
if (sending) return;
|
|
810
|
+
|
|
811
|
+
var payload = readFormPayload();
|
|
812
|
+
var validationError = validatePayload(payload);
|
|
813
|
+
if (validationError) {
|
|
814
|
+
showError(validationError);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
sendPayload(payload);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// The Send button is type="button" with an explicit click handler rather
|
|
822
|
+
// than a form submit button. The Rebel host renders this iframe with a
|
|
823
|
+
// sandbox of "allow-scripts" only — no "allow-forms" — so native form
|
|
824
|
+
// submission is blocked by the browser BEFORE the submit event fires
|
|
825
|
+
// ("Blocked form submission ... the form's frame is sandboxed and the
|
|
826
|
+
// allow-forms permission is not set"). A submit-driven Send therefore
|
|
827
|
+
// silently did nothing: no postMessage, no spinner, no telemetry.
|
|
828
|
+
// A click handler needs no sandbox permission and is the same path
|
|
829
|
+
// Cancel/Retry already use. The submit listener below stays only as a
|
|
830
|
+
// defensive fallback for hosts that DO grant allow-forms; in this sandbox
|
|
831
|
+
// it never fires (neither the button click nor Enter routes through it).
|
|
832
|
+
// The sending guard keeps any double-fire (in an allow-forms host) a no-op.
|
|
833
|
+
sendButton.addEventListener('click', function () {
|
|
834
|
+
handleSendRequest();
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
composeForm.addEventListener('submit', function (event) {
|
|
838
|
+
event.preventDefault();
|
|
839
|
+
handleSendRequest();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
retryButton.addEventListener('click', function () {
|
|
843
|
+
if (sending) return;
|
|
844
|
+
var payload = readFormPayload();
|
|
845
|
+
var validationError = validatePayload(payload);
|
|
846
|
+
if (validationError) {
|
|
847
|
+
showError(validationError);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
sendPayload(payload);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
cancelButton.addEventListener('click', function () {
|
|
854
|
+
if (sending) return;
|
|
855
|
+
// After a lost-reply timeout we don't know if the email sent, so don't
|
|
856
|
+
// claim "Nothing sent." on collapse — point the user to verify instead.
|
|
857
|
+
var collapseMessage = outcomeUnknown
|
|
858
|
+
? 'Collapsed. Check whether it sent if you are not sure.'
|
|
859
|
+
: 'Draft collapsed. Nothing sent.';
|
|
860
|
+
clearError();
|
|
861
|
+
clearSuccess();
|
|
862
|
+
clearUnknown();
|
|
863
|
+
setCollapsed(true, collapseMessage);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
reopenButton.addEventListener('click', function () {
|
|
867
|
+
setCollapsed(false);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
sentCollapseButton.addEventListener('click', function () {
|
|
871
|
+
setCollapsed(true, collapsedMessage.textContent || 'Message sent.');
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
[toInput, bodyInput].forEach(function (element) {
|
|
875
|
+
element.addEventListener('input', function () {
|
|
876
|
+
clearError();
|
|
877
|
+
clearSuccess();
|
|
878
|
+
clearUnknown();
|
|
879
|
+
postResize();
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
window.addEventListener('message', function (event) {
|
|
884
|
+
// Honest scope (origin-independent): the iframe sandbox origin is 'null',
|
|
885
|
+
// so validating event.origin is unreliable across dev/prod/custom-scheme
|
|
886
|
+
// hosts. Instead reject any *identified* sender that isn't our host frame
|
|
887
|
+
// (window.parent). A real cross-frame postMessage always stamps
|
|
888
|
+
// event.source with the sending window, so a hostile sibling frame is
|
|
889
|
+
// rejected here; a null source only occurs for same-context synthetic
|
|
890
|
+
// dispatch (our own code) and cannot be forged from outside, so we
|
|
891
|
+
// tolerate it. This does not authenticate the host's identity — the host
|
|
892
|
+
// separately authenticates this iframe by origin+source.
|
|
893
|
+
if (event.source && event.source !== window.parent) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
var data = event.data;
|
|
898
|
+
if (!data || typeof data !== 'object') {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (data.method === 'ui/notifications/tool-result') {
|
|
903
|
+
// Always parse (so the migration-shim's malformed-envelope warning
|
|
904
|
+
// still fires), but apply only the first successful prefill: a stray
|
|
905
|
+
// or hostile re-post can't silently rewrite a draft the user has
|
|
906
|
+
// already reviewed.
|
|
907
|
+
var draft = getDraftFromToolResult(data.params);
|
|
908
|
+
if (draft && !draftApplied) {
|
|
909
|
+
draftApplied = true;
|
|
910
|
+
applyDraftData(draft);
|
|
911
|
+
}
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (data.jsonrpc !== '2.0' || !pendingRequestId || data.id !== pendingRequestId) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
pendingRequestId = null;
|
|
920
|
+
clearSendTimeout();
|
|
921
|
+
clearUnknown();
|
|
922
|
+
setSending(false);
|
|
923
|
+
|
|
924
|
+
if (data.error) {
|
|
925
|
+
var errorMessage = data.error && typeof data.error.message === 'string'
|
|
926
|
+
? data.error.message
|
|
927
|
+
: 'Failed to send message.';
|
|
928
|
+
showError(errorMessage);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
clearError();
|
|
933
|
+
clearSuccess();
|
|
934
|
+
// Preserve what was sent instead of discarding the draft. pendingPayload
|
|
935
|
+
// is the exact payload we posted. We default to the collapsed summary, but
|
|
936
|
+
// Reopen now restores a read-only view of the sent message.
|
|
937
|
+
var sentPayload = pendingPayload || readFormPayload();
|
|
938
|
+
var stamp = formatSentTime();
|
|
939
|
+
renderSentView(sentPayload, getSendMetaFromResult(data.result), stamp);
|
|
940
|
+
sent = true;
|
|
941
|
+
setCollapsed(true, stamp ? 'Message sent · ' + stamp : 'Message sent.');
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
if (typeof ResizeObserver === 'function' && document.body) {
|
|
945
|
+
var observer = new ResizeObserver(postResize);
|
|
946
|
+
observer.observe(document.body);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
applyThemeFromHostContext();
|
|
950
|
+
setCollapsed(false);
|
|
951
|
+
postResize();
|
|
952
|
+
postReady();
|
|
953
|
+
|
|
954
|
+
window.parent.postMessage(
|
|
955
|
+
{
|
|
956
|
+
jsonrpc: '2.0',
|
|
957
|
+
method: 'ui/initialize',
|
|
958
|
+
params: { resourceUri: RESOURCE_URI },
|
|
959
|
+
id: 'compose-email-init'
|
|
960
|
+
},
|
|
961
|
+
'*'
|
|
962
|
+
);
|
|
963
|
+
})();
|
|
964
|
+
</script>
|
|
965
|
+
</body>
|
|
966
|
+
</html>
|
|
967
|
+
`;
|
|
968
|
+
//# sourceMappingURL=compose-message-template.js.map
|
package/dist/server.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { SERVER_NAME, SERVER_VERSION } from './types.js';
|
|
3
|
-
import { registerTeamsTools } from './tools.js';
|
|
3
|
+
import { COMPOSE_MESSAGE_RESOURCE_URI, registerTeamsTools } from './tools.js';
|
|
4
|
+
import { COMPOSE_MESSAGE_HTML } from './resources/compose-message-template.js';
|
|
4
5
|
export function createServer() {
|
|
5
6
|
const server = new McpServer({
|
|
6
7
|
name: SERVER_NAME,
|
|
7
8
|
version: SERVER_VERSION,
|
|
8
9
|
});
|
|
9
10
|
registerTeamsTools(server);
|
|
11
|
+
server.registerResource('compose-message', COMPOSE_MESSAGE_RESOURCE_URI, { mimeType: 'text/html' }, async (uri) => ({
|
|
12
|
+
contents: [{ uri: uri.href, mimeType: 'text/html', text: COMPOSE_MESSAGE_HTML }],
|
|
13
|
+
}));
|
|
10
14
|
return server;
|
|
11
15
|
}
|
|
12
16
|
//# sourceMappingURL=server.js.map
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* `ui://` resource URI the compose_chat_message iframe is served under. The
|
|
4
|
+
* shared compose-app template posts `send_chat_message` when the user clicks
|
|
5
|
+
* Send; the HTML twin lives in src/resources/compose-message-template.ts
|
|
6
|
+
* (generated from the shared compose-app package, drift-gated).
|
|
7
|
+
*/
|
|
8
|
+
export declare const COMPOSE_MESSAGE_RESOURCE_URI = "ui://microsoft-teams/compose-message";
|
|
2
9
|
export declare function registerTeamsTools(server: McpServer): void;
|
|
3
10
|
//# sourceMappingURL=tools.d.ts.map
|
package/dist/tools.js
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { callGraph } from './client.js';
|
|
3
4
|
import { successJson, withErrorHandling } from './utils.js';
|
|
4
5
|
import { getChat, getPresence, listChannels, listChats, listChatMessages, listTeams, sendChatMessage, } from './teams.js';
|
|
6
|
+
/**
|
|
7
|
+
* `ui://` resource URI the compose_chat_message iframe is served under. The
|
|
8
|
+
* shared compose-app template posts `send_chat_message` when the user clicks
|
|
9
|
+
* Send; the HTML twin lives in src/resources/compose-message-template.ts
|
|
10
|
+
* (generated from the shared compose-app package, drift-gated).
|
|
11
|
+
*/
|
|
12
|
+
export const COMPOSE_MESSAGE_RESOURCE_URI = 'ui://microsoft-teams/compose-message';
|
|
13
|
+
const ANSI_ESCAPE_SEQUENCE_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
14
|
+
const HTML_TAG_PATTERN = /<[^>]*>/g;
|
|
15
|
+
function truncateText(value, maxChars) {
|
|
16
|
+
if (value.length <= maxChars)
|
|
17
|
+
return value;
|
|
18
|
+
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
19
|
+
}
|
|
20
|
+
function sanitizeViewSummaryPart(value) {
|
|
21
|
+
return value.replace(ANSI_ESCAPE_SEQUENCE_PATTERN, '').replace(HTML_TAG_PATTERN, '').trim();
|
|
22
|
+
}
|
|
5
23
|
const READ_ANNOTATIONS = {
|
|
6
24
|
readOnlyHint: true,
|
|
7
25
|
openWorldHint: true,
|
|
@@ -34,12 +52,68 @@ export function registerTeamsTools(server) {
|
|
|
34
52
|
}).shape,
|
|
35
53
|
annotations: READ_ANNOTATIONS,
|
|
36
54
|
}, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => listChatMessages(c, args, signal)))));
|
|
55
|
+
// ---------------------------------------------------------------------
|
|
56
|
+
// compose_chat_message
|
|
57
|
+
// ---------------------------------------------------------------------
|
|
58
|
+
// Returns an editable draft the host renders as an interactive compose view
|
|
59
|
+
// (the compose-message iframe). Performs no Graph send itself — the iframe
|
|
60
|
+
// invokes send_chat_message when the user clicks Send. Registered WITHOUT
|
|
61
|
+
// withErrorHandling: it does no send I/O, and its InvalidParams validation
|
|
62
|
+
// must surface as-is rather than as an auth-flavoured retry envelope. Teams
|
|
63
|
+
// routes purely by chatId, so there is no recipient resolution (unlike Slack
|
|
64
|
+
// DMs) — the draft carries the target chat + text verbatim.
|
|
65
|
+
server.registerTool('compose_chat_message', {
|
|
66
|
+
description: `Open an inline editable message compose form before sending to a Teams chat. Use this when the user wants to write or send a Teams message so they can review and edit it first. Does NOT send directly — the form posts the message (via send_chat_message) when the user clicks Send.
|
|
67
|
+
|
|
68
|
+
PARAMETERS: target (the chat ID to send to), text (message content).`,
|
|
69
|
+
inputSchema: z
|
|
70
|
+
.object({
|
|
71
|
+
target: z.string().min(1).describe('The chat ID to send the message to'),
|
|
72
|
+
text: z.string().min(1).describe('Message content (HTML supported)'),
|
|
73
|
+
})
|
|
74
|
+
.strict(),
|
|
75
|
+
annotations: WRITE_ANNOTATIONS,
|
|
76
|
+
}, async (args) => {
|
|
77
|
+
const target = typeof args.target === 'string' ? args.target.trim() : '';
|
|
78
|
+
const text = typeof args.text === 'string' ? args.text : '';
|
|
79
|
+
if (target.length === 0 || text.trim().length === 0) {
|
|
80
|
+
throw new McpError(ErrorCode.InvalidParams, 'compose_chat_message requires a non-empty "target" (chat ID) and "text". Provide both so the editable draft has content for the user to review.');
|
|
81
|
+
}
|
|
82
|
+
const structuredContent = { target, text };
|
|
83
|
+
const viewSummaryTarget = truncateText(sanitizeViewSummaryPart(target), 120);
|
|
84
|
+
const viewSummary = truncateText(`Teams message draft to ${viewSummaryTarget || '(no target)'}.`, 280);
|
|
85
|
+
const fallbackTarget = truncateText(target, 256);
|
|
86
|
+
const fallbackText = truncateText(text, 5_000);
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: 'text',
|
|
91
|
+
text: `Drafting a Teams message to ${target}\n\n${JSON.stringify(structuredContent)}\n\n[View: ${COMPOSE_MESSAGE_RESOURCE_URI}]`,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
_meta: {
|
|
95
|
+
ui: {
|
|
96
|
+
resourceUri: COMPOSE_MESSAGE_RESOURCE_URI,
|
|
97
|
+
presentation: 'primary',
|
|
98
|
+
viewSummary,
|
|
99
|
+
viewRoleLabel: 'Teams message',
|
|
100
|
+
structuredFallback: {
|
|
101
|
+
kind: 'plain',
|
|
102
|
+
payload: {
|
|
103
|
+
markdown: `Message to ${fallbackTarget}:\n\n${fallbackText}`,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
structuredContent,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
37
111
|
server.registerTool('send_chat_message', {
|
|
38
112
|
description: 'Send a message to a chat.',
|
|
39
113
|
inputSchema: z.object({
|
|
40
114
|
chatId: z.string().describe('Chat ID'),
|
|
41
115
|
content: z.string().describe('Message content (HTML supported)'),
|
|
42
|
-
}).
|
|
116
|
+
}).strict(),
|
|
43
117
|
annotations: WRITE_ANNOTATIONS,
|
|
44
118
|
}, withErrorHandling(async (args, extra) => successJson(await callGraph(extra, (c, signal) => sendChatMessage(c, args, signal)))));
|
|
45
119
|
server.registerTool('list_teams', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindstone/mcp-server-microsoft-teams",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"mcpName": "io.github.mindstone/mcp-server-microsoft-teams",
|
|
5
5
|
"description": "Microsoft 365 Teams via Graph: list chats, read/send messages, list teams/channels, presence.",
|
|
6
6
|
"license": "FSL-1.1-MIT",
|
|
@@ -31,9 +31,12 @@
|
|
|
31
31
|
"prepublishOnly": "npm run build && npm test",
|
|
32
32
|
"watch": "tsc --watch",
|
|
33
33
|
"start": "node dist/index.js",
|
|
34
|
+
"pretest": "npm run compose:template:check",
|
|
34
35
|
"test": "vitest run",
|
|
35
36
|
"test:watch": "vitest",
|
|
36
37
|
"test:coverage": "vitest run --coverage",
|
|
38
|
+
"compose:template": "node scripts/gen-compose-html.mjs",
|
|
39
|
+
"compose:template:check": "node scripts/gen-compose-html.mjs --check",
|
|
37
40
|
"security:internal-refs": "node scripts/check-internal-refs.mjs"
|
|
38
41
|
},
|
|
39
42
|
"dependencies": {
|
|
@@ -42,6 +45,7 @@
|
|
|
42
45
|
"zod": "^3.23.0"
|
|
43
46
|
},
|
|
44
47
|
"devDependencies": {
|
|
48
|
+
"@mindstone/mcp-app-compose": "file:../../packages/mcp-app-compose",
|
|
45
49
|
"@mindstone/mcp-test-harness": "file:../../test-harness",
|
|
46
50
|
"@types/node": "^22",
|
|
47
51
|
"@vitest/coverage-v8": "^4.1.3",
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.mindstone/mcp-server-microsoft-teams",
|
|
4
4
|
"title": "Microsoft 365 Teams",
|
|
5
5
|
"description": "Microsoft 365 Teams via Graph: list chats, read/send messages, list teams/channels, presence.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/mindstone/mcp-servers",
|
|
9
9
|
"source": "github",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
16
|
"identifier": "@mindstone/mcp-server-microsoft-teams",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.2.0",
|
|
18
18
|
"runtimeHint": "npx",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|