1import {createMenu} from './menu.mjs'; 2import {icon} from './icons.mjs'; 3import {scopeTag} from './ui.mjs'; 4 5export const WEBHOOK_EVENTS = [ 6 ['meeting.transcript_ready', 'Final transcript is available'], 7 ['output.created', 'An actionâs result was saved (minutes or another)'], 8 ['action_items.created', 'Action items were extracted'], 9]; 10const el = (tag, text, cls) => { 11 const node = document.createElement(tag); 12 if (text !== undefined) node.textContent = text; 13 if (cls) node.className = cls; 14 return node; 15}; 16const BUTTON_CLASSES = {primary: 'btn btn--primary', danger: 'btn btn--danger'}; 17const button = (text, action, cls) => { 18 const node = el('button', text, BUTTON_CLASSES[cls] || ['btn', cls].filter(Boolean).join(' ')); node.type = 'button'; node.onclick = action; return node; 19}; 20 21/** Workspace-scoped management. The service is authoritative for role and delivery state. 22 * Settings ⺠Automations supplies the page title and navigation; the webhooks card names its 23 * scope. */ 24export async function openAutomations({api, host, identity}) { 25 let disposed = false, revision = 0, loading=false, snapshot=null; 26 let menus=[];const testRequests=new Map(); 27 const draftKey=identity.user_id&&identity.workspace_id?`minutes:webhook-draft:${identity.user_id}:${identity.workspace_id}`:null; 28 let pendingCreate=null; 29 try{const draft=JSON.parse(sessionStorage.getItem(draftKey)||'null');if(draft&&typeof draft.operation_id==='string'&&typeof draft.url==='string'&&typeof draft.name==='string'&&Array.isArray(draft.events))pendingCreate=draft;}catch{} 30 const keepDraft=value=>{pendingCreate=value;try{if(draftKey){if(value)sessionStorage.setItem(draftKey,JSON.stringify(value));else sessionStorage.removeItem(draftKey);}}catch{}add.textContent=value?'Finish webhook setup':'Add webhook';if(!value)add.prepend(icon('plus'));}; 31 const page = el('section', undefined, 'automations-page'); 32 const card = el('section', undefined, 'automations-card'); 33 const header = el('div', undefined, 'automations-card-heading'); 34 const intro = el('div'); intro.append(el('h2', 'Webhooks'), el('p', 'A signed POST when these events happen. Failures retry 5 times over 24 hours, then pause.')); 35 const status = el('p', '', 'automation-status'); status.setAttribute('role', 'status'); 36 const list = el('div', undefined, 'automation-list'); 37 const columns=el('div',undefined,'automation-columns');for(const label of ['Endpoint · HTTPS','Events','Status'])columns.append(el('span',label)); 38 const add = button(pendingCreate?'Finish webhook setup':'Add webhook', () => edit(), 'primary'); add.disabled = true; 39 if(!pendingCreate)add.prepend(icon('plus')); 40 const refresh=button("Refresh status",()=>load(),"btn--quiet btn--sm automation-refresh");refresh.prepend(icon('refresh-cw')); 41 const title=el('div',undefined,'automations-card-title');title.append(intro.firstElementChild,scopeTag(identity.workspace_kind==='team'?'team':'you'));intro.prepend(title); 42 header.append(intro, add); card.append(header, status, columns, list); 43 const footer=el('div',undefined,'automations-card-footer');footer.append(refresh); 44 const shortcuts=el('section',undefined,'automations-shortcuts'); 45 const shortcutIcon=el('span',undefined,'automations-shortcuts-icon');shortcutIcon.append(icon('zap')); 46 const shortcutCopy=el('div');shortcutCopy.append(el('h2','Shortcuts on iPhone'),el('p','Members can use Minutes actions in Shortcuts: Get meeting minutes, Get action items, Start recording. They run on that personâs iPhone. Read actions return saved outputs; Shortcuts canât start on our events, so use webhooks for that.')); 47 shortcuts.append(shortcutIcon,shortcutCopy); 48 page.append(shortcuts, card, footer, el('p', 'Using Zapier or Make? Create a âcatch webhookâ step there and paste its URL here â no separate integration needed.', 'muted')); 49 host.replaceChildren(page); 50 const current = () => !disposed && page.isConnected; 51 const errorText = 'Webhooks could not be loaded. Try again.'; 52 async function load({quiet=false}={}) { 53 const stamp = ++revision;loading=true; 54 if(!quiet)status.replaceChildren(el('span', 'Loading webhooksâ¦')); 55 try { 56 const result = await api('/api/v2/webhooks'); 57 if (!current() || stamp !== revision) return; 58 add.disabled = !result.can_manage; 59 status.textContent = result.can_manage ? '' : 'Only workspace admins can manage webhooks.'; 60 const next=JSON.stringify(result); 61 if(next===snapshot)return; 62 if(quiet && (page.querySelector('dialog[open]') || list.contains(document.activeElement)))return; 63 snapshot=next;
64 menus.forEach(menu=>menu.close());menus=[];list.replaceChildren(); 65 if (!result.webhooks.length) list.append(el('p', 'No webhooks yet. Add one to send meeting events to your tools.')); 66 for (const hook of result.webhooks) { 67 const row = el('article', undefined, 'automation-row'); 68 const endpoint = el('div'); endpoint.append(el('code', hook.endpoint_display), el('p', hook.name || 'Webhook')); 69 const events = el('div', undefined, 'automation-events'); for (const event of hook.events) events.append(el('code', event)); 70 const state = el('div'); state.append(el('span', {active:hook.last_attempt_at?'Delivering':'Waiting for events',retrying:'Failing · retrying',paused:'Paused'}[hook.status] || 'Waiting', `automation-badge ${hook.status}`)); 71 state.firstElementChild.prepend(icon({active:hook.last_attempt_at?'check':'clock',retrying:'alert-triangle',paused:'clock'}[hook.status]||'clock')); 72 if (hook.last_attempt_at) state.append(el('p', `Last: ${new Date(hook.last_attempt_at).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}${hook.last_status ? ` · ${hook.last_status}` : ''}`)); 73 if (hook.next_attempt_at) state.append(el('p', `Next try: ${new Date(hook.next_attempt_at).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit'})}`)); 74 if (result.can_manage) { 75 const controls = el('div', undefined, 'automation-controls'); 76 const mutate = async (suffix) => { 77 controls.querySelectorAll('button').forEach(n => n.disabled = true); 78 try { 79 const body=suffix==='test'?{operation_id:testRequests.get(hook.id)||crypto.randomUUID()}:{}; 80 if(suffix==='test')testRequests.set(hook.id,body.operation_id); 81 await api(`/api/v2/webhooks/${encodeURIComponent(hook.id)}/${suffix}`, {method:'POST',body:JSON.stringify(body)}); 82 if(suffix==='test')testRequests.delete(hook.id); 83 if (current()){await load();if(suffix==='test')status.textContent='Test event queued. The delivery status will update after it is sent.';} 84 } 85 catch { if(current()) status.textContent='The webhook could not be updated. Try again.'; } 86 finally {controls.querySelectorAll('button').forEach(n=>n.disabled=false);} 87 }; 88 const more=button('',()=>{},'btn--quiet btn--sm btn--icon');more.append(icon('more-horizontal'));more.setAttribute('aria-label',`Options for ${hook.name||hook.endpoint_display}`); 89 const menu=createMenu(more);menus.push(menu);more.onclick=()=>menu.open([ 90 {label:hook.status==='paused'?'Resume':'Pause',action:()=>mutate(hook.status==='paused'?'resume':'pause')}, 91 ...(hook.status==='paused'?[]:[{label:'Send test event',action:()=>mutate('test')}]), 92 {separator:true}, 93 {label:'Delete webhookâ¦',icon:'trash-2',danger:true,action:()=>remove(hook)} 94 ]);controls.append(more); 95 if (hook.status === 'retrying') controls.append(button('Try again now', () => mutate('retry'))); 96 state.append(controls); 97 } 98 row.append(endpoint, events, state);list.append(row); 99 } 100 } catch { if (current() && stamp === revision) status.replaceChildren(el('span', errorText), button('Try again', load)); } finally {if(stamp===revision)loading=false;} 101 } 102 function containFocus(dialog){ 103 dialog.addEventListener('keydown',event=>{ 104 if(event.key!=='Tab')return; 105 const controls=[...dialog.querySelectorAll('button,input,select,[tabindex="0"]')].filter(n=>!n.disabled&&n.getClientRects().length); 106 const first=controls[0],last=controls.at(-1); 107 if(!first){event.preventDefault();return;} 108 if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();} 109 else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();} 110 }); 111 } 112 function remove(hook){ 113 const dialog=el('dialog',undefined,'webhook-delete');const title=el('h2',`Delete â${hook.name||hook.endpoint_display}â?`);title.id='webhook-delete-title';dialog.setAttribute('aria-labelledby',title.id); 114 const error=el('p');error.setAttribute('role','alert');let busy=false; 115 const cancel=button('Cancel',()=>dialog.close());const confirm=button('Delete webhook',async()=>{ 116 if(busy)return;busy=true;cancel.disabled=confirm.disabled=true;
117 try{await api(`/api/v2/webhooks/${encodeURIComponent(hook.id)}`,{method:'DELETE'});dialog.close();if(current())await load();} 118 catch{error.textContent='Deletion was not confirmed. Try again.';} 119 finally{busy=false;cancel.disabled=confirm.disabled=false;} 120 },'danger'); 121 dialog.append(title,el('p','This stops future events and removes queued deliveries. A request already in progress may still arrive. Meetings are unchanged.'),error,cancel,confirm); 122 dialog.addEventListener('cancel',e=>{if(busy)e.preventDefault();});dialog.addEventListener('close',()=>dialog.remove());page.append(dialog);containFocus(dialog);dialog.showModal();cancel.focus(); 123 } 124 function edit() { 125 const dialog = el('dialog', undefined, 'webhook-editor'); 126 const form = el('form'); const title = el('h2','Add webhook');title.id='webhook-editor-title';dialog.setAttribute('aria-labelledby',title.id); 127 const close = button('',()=>dialog.close(),'btn--quiet btn--icon');close.append(icon('x'));close.setAttribute('aria-label','Close'); 128 const header=el('header');header.append(title,close); 129 const content=el('div',undefined,'webhook-editor-body'); 130 const urlLabel=el('label','Endpoint URL');const url=el('input');url.type='url';url.required=true;url.placeholder='https://your-tool.example/hooks/minutes';urlLabel.append(url); 131 const nameLabel=el('label','Name · optional');const name=el('input');name.maxLength=120;nameLabel.append(name); 132 const fields=el('fieldset');fields.append(el('legend','Events')); 133 for(const [value,description] of WEBHOOK_EVENTS){const label=el('label',undefined,'webhook-event');const check=el('input');check.type='checkbox';check.value=value;check.name='event';const copy=el('span');copy.append(el('code',value),el('small',description));label.append(check,copy);fields.append(label);} 134 const error=el('p','','automation-status');error.setAttribute('role','alert'); 135 const example=el('pre',JSON.stringify({event:'output.created',event_id:'example-event',meeting_id:'example-meeting',output_id:'example-output',sent_at:'2026-09-24T11:40:00Z'},null,2),'webhook-example'); 136 content.append(urlLabel,el('p','Must be HTTPS. We never follow redirects.','muted'),nameLabel,fields,el('h3','Signing secret'),el('p','Your signing secret appears after you add the webhook. Save it then to verify the Minutes-Signature header (HMAC-SHA256).','muted'),el('h3','Example payload'),example,el('p','Payloads carry IDs, not meeting content. Fetch content with an API key.','muted'),error); 137 const footer=el('footer');const cancel=button('Cancel',()=>dialog.close());const save=el('button','Add webhook','btn btn--primary');save.type='submit';footer.append(cancel,save);form.append(header,content,footer);dialog.append(form);page.append(dialog); 138 let busy=false;const operationID=pendingCreate?.operation_id||crypto.randomUUID();let submitted=pendingCreate; 139 if(submitted){url.value=submitted.url;name.value=submitted.name;fields.querySelectorAll('input').forEach(n=>n.checked=submitted.events.includes(n.value));form.querySelectorAll('input').forEach(n=>n.disabled=true);save.textContent='Check and finish';error.textContent='Finish the previous request before adding another webhook. Its signing secret has not been acknowledged.';} 140 dialog.addEventListener('cancel',event=>{if(busy)event.preventDefault();});dialog.addEventListener('close',()=>dialog.remove()); 141 form.onsubmit=async event=>{ 142 event.preventDefault();if(busy)return; 143 const events=[...fields.querySelectorAll('input:checked')].map(n=>n.value); 144 let parsed;try{parsed=new URL(url.value);}catch{} 145 if(!parsed || parsed.protocol!=='https:' || parsed.username || parsed.password || parsed.hash){error.textContent='Enter an HTTPS endpoint without a username, password or fragment.';url.focus();return;} 146 if(!events.length){error.textContent='Choose at least one event.';fields.querySelector('input').focus();return;} 147 submitted??={operation_id:operationID,url:parsed.href,name:name.value.trim(),events}; 148 keepDraft(submitted); 149 busy=true;error.textContent='Adding webhookâ¦';form.querySelectorAll('button,input').forEach(n=>n.disabled=true);
150 try{ 151 const result=await api('/api/v2/webhooks',{method:'POST',body:JSON.stringify(submitted)}); 152 if(!current() || !dialog.isConnected)return; 153 content.replaceChildren(el('h3','Webhook added'),el('p','Save this signing secret, then choose Done to finish setup.'),el('code',result.signing_secret,'webhook-secret'),el('p','Check the Minutes-Signature header (HMAC-SHA256) on each request.')); 154 const copy=button('Copy signing secret',async()=>{try{await navigator.clipboard.writeText(result.signing_secret);copy.textContent='Copied';}catch{copy.textContent='Select and copy the secret above';}}); 155 const testStatus=el('p');testStatus.setAttribute('role','status');let testID=crypto.randomUUID(); 156 const testButton=button('Send test event',async()=>{ 157 testButton.disabled=true;testStatus.textContent='Queuing test eventâ¦'; 158 try{await api(`/api/v2/webhooks/${encodeURIComponent(result.id)}/test`,{method:'POST',body:JSON.stringify({operation_id:testID})});testStatus.textContent='Test event queued. Check its delivery status in the list.';testID=crypto.randomUUID();} 159 catch{testStatus.textContent='The test event was not confirmed. Try again.';} 160 finally{testButton.disabled=false;} 161 }); 162 content.append(copy,testStatus);footer.replaceChildren(testButton,button('Done',()=>{keepDraft(null);dialog.close();},'primary'));close.disabled=false;copy.focus();await load(); 163 }catch(failure){if(current()){ 164 if([400,403,422,429].includes(failure.status)){ 165 submitted=null;keepDraft(null);form.querySelectorAll('button,input').forEach(n=>n.disabled=false); 166 error.textContent=failure.status===403?'Only workspace admins can add webhooks.':'The webhook could not be added. Check the HTTPS endpoint and selected events.'; 167 }else error.textContent='We could not confirm the webhook was added. Try again with the same details.'; 168 save.disabled=false;save.textContent='Try again';cancel.disabled=false;close.disabled=false; 169 }} 170 finally{busy=false;} 171 }; 172 containFocus(dialog);dialog.showModal();url.focus(); 173 } 174 await load(); 175 const poll=setInterval(()=>{if(current()&&!loading&&!document.hidden&&!page.querySelector('dialog[open]')&&!list.contains(document.activeElement))load({quiet:true});},5000); 176 return () => { disposed=true;clearInterval(poll); menus.forEach(menu=>menu.close()); ++revision; page.querySelectorAll('dialog').forEach(dialog=>dialog.close()); }; 177}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.