/** * Bizer teamのGmail通知をGoogle Chatへ転送するGASコードです。 * * このコードでできること: * 1. Gmailに届いたBizer teamの通知メールを探します。 * 2. 前回処理した時刻より後に届いた通知だけを対象にします(同じ通知の再送を防ぎます)。 * 3. 通知の件名、コメント、タスク名、期限、URLなどを読み取ります。 * 4. 指定したGoogle Chatスペースへ通知を投稿します。 * 5. 処理した最新メールの受信時刻を記録し、同じ通知を二重に送らないようにします。 * * 初回セットアップの流れ: * 1. Google ChatでWebhook URLを発行します。 * 2. GASのスクリプトプロパティに「WEBHOOK_URL」という名前でWebhook URLを登録します。 * 3. markExistingBizerNotificationsAsProcessed() を1回実行します。 * 現在時刻を処理基準として記録し、過去通知が一気にChatへ流れないようにします。 * 4. setTrigger() を1回実行します。 * 以後、GASが定期的にGmailを確認してGoogle Chatへ転送します。 * * 通常、お客様が変更する必要があるのはスクリプトプロパティのWEBHOOK_URLだけです。 * このファイル内にWebhook URLを直接書かないでください。 */ const DEFAULT_CONFIG = { // Bizer teamの通知メール送信元です。通常は変更不要です。 senderEmail: 'noreply@bizer.team', // 転送済みの印としてGmailに付けるラベル名です。通常は変更不要です。 processedLabelName: 'bizer_chat_forwarded', // Chatに表示する基本文面です。'subject'(件名中心)か 'body'(本文中心)を選べます。 messageMode: 'subject', // 'subject' または 'body' // 1回の実行で最大何件までChatへ転送するかを指定します。 maxThreads: 20, // コメント本文などを表示するときの最大文字数です。 bodyPreviewLength: 120, // Chatへ転送しないメール件名のキーワードです。 excludedSubjectKeywords: ['ダイジェスト', '今日のダイジェスト', '今日期限のタスク', 'グループの更新情報'], }; /** * 1分ごとの自動実行トリガーを作成します。 * 初回セットアップでは、必要に応じてmarkExistingBizerNotificationsAsProcessed()を先に実行してから、 * この関数を実行してください。 */ function setTrigger() { deleteExistingTriggers_(); ScriptApp.newTrigger('forwardBizerNotifications') .timeBased() .everyMinutes(1) .create(); } /** * Bizer teamの通知メールをGoogle Chatへ転送します。 */ function forwardBizerNotifications() { const config = getConfig_(); const label = getOrCreateLabel_(config.processedLabelName); const properties = PropertiesService.getScriptProperties(); // 前回までに処理した最終受信時刻を読み込みます。重複送信はこの時刻で防ぎます。 const storedTime = properties.getProperty('LAST_PROCESSED_TIME'); // 初回(未設定)は現在時刻を基準として記録し、過去メールの一斉送信を防ぎます。 if (!storedTime) { properties.setProperty('LAST_PROCESSED_TIME', String(Date.now())); return; } const lastProcessedTime = Number(storedTime); // 直近1日以内のBizer team通知を取得します。 const query = [`from:${config.senderEmail}`, 'newer_than:1d'].join(' '); const threads = GmailApp.search(query, 0, 500); // スレッド内の全メッセージを集め、前回処理時刻より後に届いたものだけを対象にします。 const newMessages = []; threads.forEach((thread) => { thread.getMessages().forEach((message) => { if (message.getDate().getTime() > lastProcessedTime) { newMessages.push(message); } }); }); // 届いた日時の古い順に並べ替え、1回の上限件数までに絞ります。 const targetMessages = newMessages .sort((a, b) => a.getDate().getTime() - b.getDate().getTime()) .slice(0, config.maxThreads); let maxProcessedTime = lastProcessedTime; targetMessages.forEach((message) => { const messageTime = message.getDate().getTime(); const subject = message.getSubject(); // ダイジェストなど、Chatへ流さない通知はここでスキップします(時刻だけ更新)。 if (isExcludedSubject_(subject, config.excludedSubjectKeywords)) { maxProcessedTime = Math.max(maxProcessedTime, messageTime); return; } const plainBody = message.getPlainBody(); const htmlBody = message.getBody(); // URLは通常本文とHTML本文の両方から探します。 const urls = extractBizerUrls_(`${plainBody}\n${htmlBody}`); // 件名だけではタスク名が分かりにくい通知に備え、本文からタスク名候補を探します。 const taskName = extractTaskName_(plainBody); const text = buildChatMessage_({ subject, body: plainBody, taskName, urls, messageMode: config.messageMode, bodyPreviewLength: config.bodyPreviewLength, }); sendToGoogleChat_(config.webhookUrl, text); // 転送済みの目印としてスレッドにラベルを付けます。 message.getThread().addLabel(label); maxProcessedTime = Math.max(maxProcessedTime, messageTime); }); // 処理した最新メッセージの受信時刻を保存し、次回以降の重複送信を防ぎます。 if (maxProcessedTime > lastProcessedTime) { properties.setProperty('LAST_PROCESSED_TIME', String(maxProcessedTime)); } } /** * 既存のBizer team通知メールに転送済みラベルを付けます。 * 初回セットアップ時に過去メールをGoogle Chatへ大量転送しないための関数です。 * Google Chatへの送信は行わず、ラベル付けだけを行います。 */ function markExistingBizerNotificationsAsProcessed() { const config = getConfig_(); const label = getOrCreateLabel_(config.processedLabelName); // 初回セットアップ時に、直近1日分の既存通知をChatへ送らずに処理済みにします。 const query = [ `from:${config.senderEmail}`, `-label:${config.processedLabelName}`, 'newer_than:1d', ].join(' '); const threads = GmailApp.search(query, 0, 500); threads.forEach((thread) => { thread.addLabel(label); }); // これ以降に届く通知だけをChatへ送るよう、現在時刻を処理基準として記録します。 PropertiesService.getScriptProperties().setProperty('LAST_PROCESSED_TIME', String(Date.now())); } /** * スクリプトプロパティから設定値を読み込みます。 * Webhook URLはコードに直接書かず、GASの設定画面から登録します。 */ function getConfig_() { const properties = PropertiesService.getScriptProperties(); const webhookUrl = properties.getProperty('WEBHOOK_URL'); if (!webhookUrl) { throw new Error('WEBHOOK_URLが未設定です。スクリプトプロパティに登録してください。'); } return { webhookUrl, senderEmail: properties.getProperty('SENDER_EMAIL') || DEFAULT_CONFIG.senderEmail, processedLabelName: properties.getProperty('PROCESSED_LABEL_NAME') || DEFAULT_CONFIG.processedLabelName, messageMode: properties.getProperty('MESSAGE_MODE') || DEFAULT_CONFIG.messageMode, maxThreads: Number(properties.getProperty('MAX_THREADS') || DEFAULT_CONFIG.maxThreads), bodyPreviewLength: Number( properties.getProperty('BODY_PREVIEW_LENGTH') || DEFAULT_CONFIG.bodyPreviewLength ), excludedSubjectKeywords: DEFAULT_CONFIG.excludedSubjectKeywords, }; } /** * Google Chatへ送る本文を作成します。 */ function buildChatMessage_({ subject, body, taskName, urls, messageMode, bodyPreviewLength }) { const summary = buildNotificationSummary_(subject, body, taskName, messageMode, bodyPreviewLength); const lines = [summary]; if (urls.length > 0) { // 1つの通知につき、最初に見つかったURLだけをそのまま表示します(直前に空行を入れません)。 lines.push(resolveDisplayUrl_(urls[0])); } return lines.join('\n'); } /** * 通知内容として表示するテキストを作成します。 * messageModeが 'body' のときは本文中心、それ以外は件名中心で組み立てます。 */ function buildNotificationSummary_(subject, body, taskName, messageMode, bodyPreviewLength) { const taskLine = buildTaskLine_(subject, taskName); const detailLines = buildNotificationDetailLines_(subject, body, taskName); if (messageMode === 'body') { const bodyPreview = buildBodyPreview_(body, bodyPreviewLength); if (bodyPreview) { return [taskLine, ...detailLines, bodyPreview].filter((line) => line).join('\n'); } } // コメント / メンション通知のときは「誰が」を文頭に出し、コメント内容を続けて表示します。 if (isCommentNotification_(subject)) { const commenter = extractCommenterName_(subject, body); const commentText = buildCommentPreview_(body, bodyPreviewLength); const headline = buildCommentHeadline_(subject, commenter); const commentLine = commentText ? `内容: ${commentText}` : ''; return [headline, taskLine, ...detailLines, commentLine] .filter((line) => line) .join('\n'); } return [subject, taskLine, ...detailLines].filter((line) => line).join('\n'); } /** * コメント / メンション通知の見出しを作ります。 * 件名にまだ操作した人が含まれていなければ、「〇〇さんが」を文頭側へ差し込みます。 * 例: 「[スペース]テスト(#4)であなたにメンションしました。」 * → 「[スペース]サトウ ヨシハルさんがテスト(#4)であなたにメンションしました。」 */ function buildCommentHeadline_(subject, commenter) { const subjectHasActor = /(さん|様|さま)が/.test(subject); if (!commenter || subject.includes(commenter) || subjectHasActor) { return subject; } // 件名が「[スペース名]…」で始まる場合は、スペース名の直後に差し込みます。 const match = subject.match(/^(\s*\[[^\]]*\]\s*)([\s\S]*)$/); if (match) { return `${match[1]}${commenter}が${match[2]}`; } return `${commenter}が${subject}`; } /** * コメントやメンション通知かどうかを判定します。 */ function isCommentNotification_(subject) { return /コメント|メンション/.test(subject); } /** * コメント・メンションをした人の名前を、本文や件名から取り出します。 * 例: 「竹村 泰昭さんがタスクであなたにメンションしました。」→「竹村 泰昭さん」 */ function extractCommenterName_(subject, body) { // 「[image: Bizer team]」のような装飾を取り除いてから探します。 const text = `${body}\n${subject}`.replace(/\[[^\]]*\]/g, ' '); const patterns = [ /([^\n\r。、::\]]+?(?:さん|様|さま))が[^。\n\r]{0,30}?(?:メンション|コメント)/, /([^\n\r。、::\]]+?(?:さん|様|さま))が/, ]; for (const pattern of patterns) { const match = text.match(pattern); if (match && match[1]) { const name = match[1].replace(/^[\s「」]+/, '').trim(); if (name) { return name; } } } return ''; } /** * メール本文から通知内容として使いやすい短い文章を作ります。 * messageModeが 'body' のときに使用します。 */ function buildBodyPreview_(body, maxLength) { const text = body .replace(/https?:\/\/\S+/g, '') .replace(/\s+/g, ' ') .trim(); if (!text) { return ''; } return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; } /** * コメント通知では本文全体ではなく、コメント部分だけを表示します。 * 「コメント:」と同じ行に内容がある場合も、次の行以降に続く場合も拾えるようにしています。 */ function buildCommentPreview_(body, maxLength) { const lines = body .split(/\r?\n/) .map((line) => normalizeBodyLine_(line)) .filter((line) => line); const commentIndex = lines.findIndex((line) => /コメント[::]/.test(line)); if (commentIndex < 0) { return ''; } // まずは「コメント:」と同じ行に内容があるかを確認します。 let comment = lines[commentIndex].replace(/^.*?コメント[::]\s*/, '').trim(); // 同じ行に内容がなければ、次の行以降をコメント本文として集めます。 if (!comment) { comment = collectCommentLines_(lines, commentIndex + 1); } comment = comment.replace(/https?:\/\/\S+/g, '').trim(); // 先頭の「@名前」(宛先のメンション)を取り除き、コメント本文だけにします。 comment = stripLeadingMentions_(comment); if (!comment) { return ''; } return comment.length > maxLength ? `${comment.slice(0, maxLength)}...` : comment; } /** * 文章の先頭にある「@名前」(メンション)を取り除きます。 * 日本語名(姓 名)のように途中に空白が1つ入るメンションにも対応します。 * 例: 「@佐藤 吉遥 確認」→「確認」 / 「@CS 今週の定例」→「今週の定例」 */ function stripLeadingMentions_(text) { let result = text.trim(); while (/^[@@]/.test(result)) { const stripped = result.replace(/^[@@]\S+(?:\s+\S+)?\s+/, '').trim(); if (stripped === result) { break; } result = stripped; } return result; } /** * 「コメント:」の次の行から、フッターや区切り線が来るまでをコメント本文として集めます。 */ function collectCommentLines_(lines, startIndex) { const collected = []; for (let i = startIndex; i < lines.length; i += 1) { const line = lines[i]; if (isCommentEndLine_(line)) { break; } collected.push(line); if (collected.join(' ').length > 200) { break; } } return collected.join(' ').trim(); } /** * コメント本文の終わり(フッター・区切り線・ボタン文言など)かどうかを判定します。 */ function isCommentEndLine_(line) { return ( /^[-—=*_\s]+$/.test(line) || /(詳細を見る|コメントする|返信|通知設定|配信(?:を)?停止|メール配信|このメール|Bizer team|support@)/.test(line) ); } /** * 本文からタスク名らしき行を取り出します。 */ function extractTaskName_(body) { const lines = body .split(/\r?\n/) .map((line) => normalizeBodyLine_(line)) .filter((line) => line); const labeledLine = lines.find((line) => { return /^(タスク名|タスク|件名|タイトル)[::]/.test(line); }); if (labeledLine) { return cleanTaskName_(labeledLine.replace(/^(タスク名|タスク|件名|タイトル)[::]\s*/, '')); } const checklistLine = lines.find((line) => { return line.includes('チェックリスト') && !line.includes('http'); }); if (checklistLine) { const match = checklistLine.match(/(.+?)のチェックリスト/); return cleanTaskName_(match ? match[1] : checklistLine); } return ''; } /** * 本文の1行をタスク名抽出に使いやすい形に整えます。 */ function normalizeBodyLine_(line) { return line .replace(/\s+/g, ' ') .replace(/[「」]/g, '') .trim(); } /** * 抽出したタスク名から不要な語尾や長すぎる部分を整えます。 */ function cleanTaskName_(taskName) { return taskName .replace(/^[-・\s]+/, '') .replace(/\s*(さん|様)?が.*$/, '') .slice(0, 120) .trim(); } /** * 件名にタスク名が含まれていない場合だけ、タスク名の行を追加します。 */ function buildTaskLine_(subject, taskName) { if (!taskName || subject.includes(taskName)) { return ''; } return `タスク: ${taskName}`; } /** * 通知種別に応じて、本文・件名から補足情報を作成します。 */ function buildNotificationDetailLines_(subject, body, taskName) { const lines = []; const checklistName = extractChecklistName_(subject, body); const dueDate = extractDueDate_(subject, body); const completedBy = extractCompletedBy_(subject, body); const assignedTarget = extractAssignedTarget_(subject, body, taskName, checklistName); if (checklistName && !subject.includes(checklistName)) { lines.push(`チェックリスト: ${checklistName}`); } if (dueDate && /期限|リマインダー/.test(subject)) { lines.push(`期限: ${dueDate}`); } if (completedBy && /完了/.test(subject)) { lines.push(`完了者: ${completedBy}`); } if (assignedTarget && /担当者に設定|担当に設定|割り当て/.test(subject)) { lines.push(`担当対象: ${assignedTarget}`); } return removeDuplicateLines_(lines); } /** * 本文からチェックリスト名らしき文字列を取り出します。 */ function extractChecklistName_(subject, body) { const text = `${subject}\n${body}`; const patterns = [ /チェックリスト名[::]\s*([^\n\r]+)/, /チェックリスト[::]\s*([^\n\r]+)/, /「([^」]+)」(?:という)?チェックリスト/, /([^。\n\r]+?)のチェックリスト/, ]; return extractByPatterns_(text, patterns); } /** * 本文から期限日らしき文字列を取り出します。 */ function extractDueDate_(subject, body) { const text = `${subject}\n${body}`; const patterns = [ /期限日[::]\s*([^\n\r]+)/, /期限[::]\s*([^\n\r]+)/, /期日[::]\s*([^\n\r]+)/, /(\d{4}[\/\-年]\d{1,2}[\/\-月]\d{1,2}日?(?:\s+\d{1,2}:\d{2})?)/, /(\d{1,2}月\d{1,2}日(?:\s+\d{1,2}:\d{2})?)/, ]; return extractByPatterns_(text, patterns); } /** * 本文から完了者らしき文字列を取り出します。 */ function extractCompletedBy_(subject, body) { const text = `${subject}\n${body}`; const patterns = [ /完了者[::]\s*([^\n\r]+)/, /完了した人[::]\s*([^\n\r]+)/, /([^。\n\r]+?)さんが.+?完了/, /([^。\n\r]+?)様が.+?完了/, ]; return extractByPatterns_(text, patterns); } /** * 本文から担当対象らしき文字列を取り出します。 */ function extractAssignedTarget_(subject, body, taskName, checklistName) { if (checklistName) { return checklistName; } if (taskName) { return taskName; } const text = `${subject}\n${body}`; const patterns = [ /担当対象[::]\s*([^\n\r]+)/, /担当者に設定された(?:タスク|チェックリスト)?[::]\s*([^\n\r]+)/, /「([^」]+)」.+?担当者に設定/, /([^。\n\r]+?)の担当者に設定/, ]; return extractByPatterns_(text, patterns); } /** * 複数の正規表現から最初に見つかった値を返します。 */ function extractByPatterns_(text, patterns) { for (const pattern of patterns) { const match = text.match(pattern); if (match && match[1]) { return cleanDetailText_(match[1]); } } return ''; } /** * 抽出した補足情報を表示しやすい形に整えます。 */ function cleanDetailText_(text) { return text .replace(/https?:\/\/\S+/g, '') .replace(/^[-・\s]+/, '') .replace(/[、。,..]+$/g, '') .slice(0, 120) .trim(); } /** * 同じ補足行が複数出ないようにします。 */ function removeDuplicateLines_(lines) { return [...new Set(lines.filter((line) => line))]; } /** * 除外対象の件名かどうかを判定します。 */ function isExcludedSubject_(subject, keywords) { return keywords.some((keyword) => subject.includes(keyword)); } /** * メール本文からBizer teamのURLを取り出します。 * HTMLメールではリンクがボタンのhref属性だけに入ることがあるため、 * プレーン本文とHTML本文を合わせた文字列から探します。 */ function extractBizerUrls_(text) { const decodedText = decodeHtmlEntities_(text); const directMatches = decodedText.match(/https:\/\/[^\s"'<>))]+/g) || []; const encodedMatches = decodedText.match(/https%3A%2F%2F[^\s"'<>))&]+/gi) || []; const decodedEncodedMatches = encodedMatches.map((url) => decodeUrlSafely_(url)); const urls = directMatches .concat(decodedEncodedMatches) .map((url) => extractNotificationUrl_(url)) .filter((url) => url); return [...new Set(urls)]; } /** * Bizer teamの直接URL、またはBizer teamへ遷移するSendGridのクリック計測URLを取り出します。 */ function extractNotificationUrl_(url) { const decodedUrl = decodeUrlSafely_(decodeHtmlEntities_(url)); const directMatch = decodedUrl.match(/https:\/\/(?:[A-Za-z0-9-]+\.)*bizer\.team(?:\/[^\s"'<>))]*)?/); if (directMatch) { return cleanUrl_(directMatch[0]); } const sendGridMatch = decodedUrl.match(/https:\/\/[^/\s"'<>))]+\.ct\.sendgrid\.net\/ls\/click\?[^\s"'<>))]+/); if (sendGridMatch) { return cleanUrl_(sendGridMatch[0]); } const redirectParamMatch = decodedUrl.match(/[?&](?:q|url|u)=([^&]+)/); if (!redirectParamMatch) { return null; } const redirectUrl = decodeUrlSafely_(redirectParamMatch[1]); const redirectMatch = redirectUrl.match(/https:\/\/(?:[A-Za-z0-9-]+\.)*bizer\.team(?:\/[^\s"'<>))]*)?/); return redirectMatch ? cleanUrl_(redirectMatch[0]) : null; } /** * Google Chatに表示するURLを整えます。 * SendGridのクリック計測URLの場合は、可能な範囲でBizer teamの遷移先URLに変換します。 */ function resolveDisplayUrl_(url) { if (!isSendGridClickUrl_(url)) { return url; } const resolvedUrl = resolveRedirectUrl_(url); return resolvedUrl || url; } /** * SendGridのクリック計測URLかどうかを判定します。 */ function isSendGridClickUrl_(url) { return /https:\/\/[^/\s"'<>))]+\.ct\.sendgrid\.net\/ls\/click\?/.test(url); } /** * リダイレクト先をたどり、Bizer teamのURLが取れた場合だけ返します。 */ function resolveRedirectUrl_(url) { let currentUrl = url; for (let i = 0; i < 5; i += 1) { const directMatch = currentUrl.match(/https:\/\/(?:[A-Za-z0-9-]+\.)*bizer\.team(?:\/[^\s"'<>))]*)?/); if (directMatch) { return cleanUrl_(directMatch[0]); } const response = UrlFetchApp.fetch(currentUrl, { method: 'get', followRedirects: false, muteHttpExceptions: true, }); const headers = response.getAllHeaders(); const location = headers.Location || headers.location; if (!location) { return null; } currentUrl = decodeUrlSafely_(decodeHtmlEntities_(String(location))); } return null; } /** * URL末尾に入り込む句読点などを取り除きます。 */ function cleanUrl_(url) { return url.replace(/[、。,..]+$/g, ''); } /** * URLデコードに失敗しても処理を止めないようにします。 */ function decodeUrlSafely_(url) { try { return decodeURIComponent(url); } catch (error) { return url; } } /** * HTMLメール内のエスケープ表記をURLとして読みやすい形に戻します。 */ function decodeHtmlEntities_(text) { return text .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>'); } /** * Google ChatのWebhook URLへメッセージを送信します。 */ function sendToGoogleChat_(webhookUrl, text) { UrlFetchApp.fetch(webhookUrl, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ text }), }); } /** * 転送済み管理用のGmailラベルを取得します。 * なければ自動で作成します。 */ function getOrCreateLabel_(labelName) { return GmailApp.getUserLabelByName(labelName) || GmailApp.createLabel(labelName); } /** * 同じ関数のトリガーが重複しないよう、既存トリガーを削除します。 */ function deleteExistingTriggers_() { ScriptApp.getProjectTriggers().forEach((trigger) => { if (trigger.getHandlerFunction() === 'forwardBizerNotifications') { ScriptApp.deleteTrigger(trigger); } }); }