python -> node.js
This commit is contained in:
parent
092f2ec0f3
commit
395fba645d
62 changed files with 726 additions and 1702 deletions
BIN
functions/generate_minutes/.DS_Store
vendored
Normal file
BIN
functions/generate_minutes/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -5,12 +5,12 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "npm run build && functions-framework --target=helloHttp --port=8080 --source=dist/index.js",
|
||||
"dev": "dotenv -e .env_dev -- nodemon --watch . --exec \"functions-framework --target=helloHttp --port=8080\"",
|
||||
"debug": "dotenv -e .env_dev -- node --inspect node_modules/.bin/functions-framework --source=dist/index.js --target=helloHttp",
|
||||
"watch": "concurrently \"dotenv -e .env_dev -- npm run build -- --watch\" \"dotenv -e .env_dev -- nodemon --watch ./dist/ --exec npm run debug\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google-cloud/functions-framework": "^3.0.0",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
|
|
@ -19,12 +19,14 @@
|
|||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/local-auth": "^2.1.0",
|
||||
"@google-cloud/storage": "^7.17.3",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@hubspot/api-client": "^13.4.0",
|
||||
"archiver": "^7.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"googleapis": "^105.0.0",
|
||||
"zod": "^4.1.13"
|
||||
}
|
||||
|
|
|
|||
24
functions/generate_minutes/serverConfig.ts
Normal file
24
functions/generate_minutes/serverConfig.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { join } from "path";
|
||||
|
||||
export const GEMINI_MODEL_ID = "gemini-2.5-flash";
|
||||
export const DEBUG = true;
|
||||
|
||||
export const CREDENTIALS_PATH = join(__dirname, process.env.SEARVICE_ACCOUNT_CREDENTIALS_FILE || '');
|
||||
|
||||
export const CLOUD_STORAGE_MASTER_FOLDER_NAME = "master";
|
||||
export const CLOUD_STORAGE_LOG_FOLDER_NAME = "request_logs";
|
||||
export const COMPANIES_FILE_NAME = "companies.json";
|
||||
export const OWNERS_FILE_NAME = "owners.json";
|
||||
|
||||
export const LEGAL_SUFFIX = /(株式会社|(株)|\(株\)|有限会社|合同会社|Inc\.?|Corp\.?|Co\.?Ltd\.?)/;
|
||||
|
||||
export const Y_FORMAT = 'yyyy';
|
||||
export const YM_FORMAT = 'yyyyMM'
|
||||
export const DATETIME_FORMAT = 'yyyy-MM-dd hh:mm:ss';
|
||||
export const DATE_FORMAT = 'yyyy年MM月dd日';
|
||||
|
||||
export const FOLDER_MIMETYPE = 'application/vnd.google-apps.folder';
|
||||
export const DOCUMENT_MIMETYPE = 'application/vnd.google-apps.document';
|
||||
export const SHEET_MIMETYPE = 'application/vnd.google-apps.spreadsheet';
|
||||
|
||||
export const LOG_SHEET_HEADER_VALUES = ["タイムスタンプ","商談日", "タイトル", "登録先企業","担当者", "ミーティングURL", "議事録URL", "HubSpot会社概要URL"]
|
||||
|
|
@ -1,28 +1,56 @@
|
|||
import express from "express";
|
||||
import zlib from "zlib";
|
||||
import { storageController } from "./logics/storage";
|
||||
import { MiiTelWebhookSchema, processRequest } from "./logics/process";
|
||||
import { hubspotController } from "./logics/hubspot";
|
||||
import { createCustomError } from "./logics/error";
|
||||
import { CLOUD_STORAGE_LOG_FOLDER_NAME, CLOUD_STORAGE_MASTER_FOLDER_NAME, COMPANIES_FILE_NAME, OWNERS_FILE_NAME } from "../serverConfig";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/hello", (req, res) => res.send("こんにちは!"));
|
||||
// Process Request From Miitel Webhook
|
||||
router.post("/miitel", async (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
const parsedBody = MiiTelWebhookSchema.safeParse(body);
|
||||
if (!parsedBody.success) throw createCustomError("ZOD_FAILED");
|
||||
|
||||
router.post("/miitel", async(req, res) => {
|
||||
const body = req.body;
|
||||
// await storageController.saveToGCS("request_log",'test', JSON.stringify(req.body));
|
||||
const videoInfo = parsedBody.data.video;
|
||||
const gzipped = zlib.gzipSync(JSON.stringify(body));
|
||||
await storageController.saveToGCS(CLOUD_STORAGE_LOG_FOLDER_NAME, `${videoInfo.id}.json.gz`, gzipped, 'application/gzip');
|
||||
|
||||
const parsedBody = MiiTelWebhookSchema.safeParse(body);
|
||||
if(!parsedBody.success) {
|
||||
console.error("Invalid webhook body:", parsedBody.error);
|
||||
return;
|
||||
await processRequest(videoInfo);
|
||||
|
||||
res.status(200).send("ok");
|
||||
} catch(err) {
|
||||
res.status(400).send("Invalid webhook body");
|
||||
}
|
||||
console.log("miitel webhook received:", parsedBody.data.video.id);
|
||||
|
||||
await processRequest(parsedBody.data.video);
|
||||
|
||||
res.send("こんにちは!");
|
||||
});
|
||||
|
||||
router.post("/getLog", async(req, res) => {
|
||||
// Update Master Data And Check Google Drive Folder
|
||||
router.post("/dailyBatch", async (req, res) => {
|
||||
try {
|
||||
console.log("Starting daily batch process...");
|
||||
// export companies to GCS
|
||||
const companies = await hubspotController.getCompanies();
|
||||
if(!companies) throw createCustomError("GET_OWNERS_FAILED");
|
||||
await storageController.saveToGCS(CLOUD_STORAGE_MASTER_FOLDER_NAME, COMPANIES_FILE_NAME, JSON.stringify(companies), 'application/json');
|
||||
|
||||
// export owners to GCS
|
||||
const owners = await hubspotController.getOwners();
|
||||
if(!owners) throw createCustomError("GET_COMPANIES_FAILED");
|
||||
await storageController.saveToGCS(CLOUD_STORAGE_MASTER_FOLDER_NAME, OWNERS_FILE_NAME, JSON.stringify(owners), 'application/json');
|
||||
|
||||
// check folders in Google Drive
|
||||
res.status(200).send("Daily batch executed.");
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in daily batch:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Check Log By Meeting ID
|
||||
router.post("/getLog", async (req, res) => {
|
||||
console.log(req.body);
|
||||
const meetingId = req.body.meetingId;
|
||||
const exist = await storageController.existsInGCS("request_log", "test.json.gz");
|
||||
|
|
@ -32,4 +60,33 @@ router.post("/getLog", async(req, res) => {
|
|||
res.send(log);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// router.post("/deleteFile", async (req, res) => {
|
||||
// console.log(req.body);
|
||||
// const fileId = req.body.fileId;
|
||||
// const googleAuth = await googleDriveController.getAuth();
|
||||
// const driveClilent = googleDriveController.getDriveClient(googleAuth);
|
||||
// await googleDriveController.deleteFile(driveClilent, fileId);
|
||||
// res.send('ok');
|
||||
// });
|
||||
|
||||
// router.post("/test", async (req, res) => {
|
||||
// try {
|
||||
|
||||
// const googleAuth = await googleDriveController.getAuth();
|
||||
// const driveClilent = googleDriveController.getDriveClient(googleAuth);
|
||||
// const sheetsClient = googleDriveController.getSheetsClient(googleAuth);
|
||||
// const folderId = await googleDriveController.searchFileIdByFileName(driveClilent, MINUTES_CREATION_HISTORY_FOLDER_ID, '2025');
|
||||
// if(!folderId) throw new Error()
|
||||
// // console.log(fileId);
|
||||
// // const sheetId = await googleDriveController.getLogSheetId(driveClilent, sheetsClient, folderId, 'test1');
|
||||
// // console.log('sheet id : ', sheetId);
|
||||
// res.send("ok");
|
||||
// } catch (error) {
|
||||
// console.error("Error in /test endpoint:", error);
|
||||
// res.status(500).send("Error in /test endpoint");
|
||||
// }
|
||||
// });
|
||||
|
||||
export default router;
|
||||
|
|
@ -21,5 +21,12 @@ export const dateController = {
|
|||
return formatted.replace(/(y+)/g, (v) =>
|
||||
date.getFullYear().toString().slice(-v.length)
|
||||
);
|
||||
}
|
||||
},
|
||||
getCurrentJstTime: (format: string) => {
|
||||
const utcDate = new Date().toUTCString();
|
||||
const jstDate = dateController.convertToJst(utcDate);
|
||||
const jstStr = dateController.getFormattedDate(jstDate, format);
|
||||
return jstStr;
|
||||
// return dateController.getFormattedDate(utcDate, "yyyy/MM/dd hh:mm:ss");
|
||||
},
|
||||
};
|
||||
29
functions/generate_minutes/src/logics/error.ts
Normal file
29
functions/generate_minutes/src/logics/error.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Response } from "express";
|
||||
import z from "zod";
|
||||
import { ERROR_DEFINITIONS, ErrorKey } from "../stores/errorCodes";
|
||||
|
||||
const CustomErrorSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
statusCode:z.number(),
|
||||
});
|
||||
|
||||
export type CustomError = z.infer<typeof CustomErrorSchema>;
|
||||
|
||||
export const createCustomError = (key: ErrorKey): CustomError => {
|
||||
const errorInfo = ERROR_DEFINITIONS[key];
|
||||
return CustomErrorSchema.parse(errorInfo);
|
||||
};
|
||||
|
||||
export const responseError = (error: any, res: Response | null = null) => {
|
||||
if (!CustomErrorSchema.safeParse(error).success) {
|
||||
console.error(error);
|
||||
console.error("========== Unknown Error ==========");
|
||||
if(res) return res.status(500).send('Internal Server Error');
|
||||
}
|
||||
const parsedError = CustomErrorSchema.parse(error);
|
||||
console.error("========== Custom Error ==========");
|
||||
console.error(`Error Code: ${parsedError.code}\n Message: ${parsedError.message}`);
|
||||
if(res) return res.status(parsedError.statusCode).send(parsedError.message);
|
||||
}
|
||||
|
||||
53
functions/generate_minutes/src/logics/file.ts
Normal file
53
functions/generate_minutes/src/logics/file.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { create } from "domain";
|
||||
import { dateController } from "./date";
|
||||
import path, { join } from "path";
|
||||
import archiver from "archiver";
|
||||
import { googleDriveController } from "./googleDrive";
|
||||
import fs from "fs";
|
||||
|
||||
|
||||
|
||||
export const fileController = {
|
||||
createMinutesFileName: (title: string, hostName: string, jstStartsAt: Date): string => {
|
||||
const dateStr = dateController.getFormattedDate(jstStartsAt, "yyyy年MM月dd日");
|
||||
const fileName = `${dateStr} ${title} ${hostName}`;
|
||||
return fileName;
|
||||
},
|
||||
extractCompanyNameFromTitle: (title: string) => {
|
||||
const normalizedTitle = title.replace("【", "").replace("】", "");
|
||||
const companyName = normalizedTitle.split("様")[0];
|
||||
return companyName
|
||||
},
|
||||
createMinutesContent: (videoUrl: string, hostName: string, minutes: string): string => {
|
||||
let minutesContent = `会議履歴URL:${videoUrl}\n`;
|
||||
minutesContent += `担当者:${hostName}\n\n`;
|
||||
minutesContent += minutes;
|
||||
return minutesContent;
|
||||
},
|
||||
createZip: async (body: any, outputPath: string, fileName: string) => {
|
||||
console.log(outputPath);
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(outputPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }
|
||||
});
|
||||
|
||||
output.on('close', () => {
|
||||
console.log(archive.pointer() + ' total bytes');
|
||||
console.log('archiver has been finalized and the output file descriptor has closed.');
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
archive.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.append(JSON.stringify(body), { name: fileName + '.json' });
|
||||
archive.finalize();
|
||||
})
|
||||
console.log("ZIP created");
|
||||
return;
|
||||
},
|
||||
|
||||
};
|
||||
62
functions/generate_minutes/src/logics/fuzzyMatch.ts
Normal file
62
functions/generate_minutes/src/logics/fuzzyMatch.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { search } from "fast-fuzzy";
|
||||
import { storageController } from "./storage";
|
||||
import { CLOUD_STORAGE_MASTER_FOLDER_NAME, COMPANIES_FILE_NAME, LEGAL_SUFFIX } from "../../serverConfig";
|
||||
import { Company, CompanySchema } from "./hubspot";
|
||||
import z from "zod";
|
||||
|
||||
|
||||
export const fuzzyMatchController = {
|
||||
searchMatchedCompany: async(companyName: string): Promise<Company | null> => {
|
||||
try {
|
||||
const companiesJson = await storageController.loadJsonFromGCS(CLOUD_STORAGE_MASTER_FOLDER_NAME, COMPANIES_FILE_NAME);
|
||||
if(!companiesJson) return null;
|
||||
const parsedCompanies = z.array(CompanySchema).safeParse(JSON.parse(companiesJson));
|
||||
if(!parsedCompanies.success) return null;
|
||||
|
||||
const normalizedCompanyName = fuzzyMatchController.normalizeCompanyName(companyName);
|
||||
const normalizedCompanies: Company[] = parsedCompanies.data.map((c) => CompanySchema.parse({
|
||||
id: c.id,
|
||||
name: fuzzyMatchController.normalizeCompanyName(c.name),
|
||||
}));
|
||||
|
||||
// Exact Match
|
||||
const exactMatchedCompany = fuzzyMatchController.searchExactMatchedCompany(normalizedCompanyName, normalizedCompanies);
|
||||
// console.log(exactMatchedCompanyId);
|
||||
if(exactMatchedCompany) return exactMatchedCompany;
|
||||
|
||||
// Fuzzy Match
|
||||
const results = search(
|
||||
fuzzyMatchController.normalizeCompanyName(companyName),
|
||||
parsedCompanies.data,
|
||||
{
|
||||
keySelector: (obj) => fuzzyMatchController.normalizeCompanyName(obj.name),
|
||||
returnMatchData: true,
|
||||
threshold: 0.8,
|
||||
},
|
||||
);
|
||||
console.log("===== Search Results =====");
|
||||
console.log(results);
|
||||
if(results.length <= 0) return null;
|
||||
if(results.length === 1) return results[0].item;
|
||||
if(results.length > 1) {
|
||||
// 同スコアが複数存在
|
||||
if(results[0].score === results[1].score) return null;
|
||||
// トップが単独の場合のみ
|
||||
return results[0].item;
|
||||
}
|
||||
return null;
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
normalizeCompanyName: (companyName: string) => {
|
||||
return companyName.replace(LEGAL_SUFFIX, '');
|
||||
},
|
||||
searchExactMatchedCompany: (companyName: string, companies: Company[]): Company | null => {
|
||||
for(const company of companies) {
|
||||
if(companyName === company.name) return company;
|
||||
};
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,36 +1,226 @@
|
|||
import { authenticate } from "@google-cloud/local-auth";
|
||||
import { JSONClient } from "google-auth-library/build/src/auth/googleauth";
|
||||
import { google } from "googleapis";
|
||||
import path from "path";
|
||||
import { docs_v1, drive_v3, google, sheets_v4 } from "googleapis";
|
||||
import fs from "fs";
|
||||
import { CREDENTIALS_PATH, DEBUG, FOLDER_MIMETYPE, LOG_SHEET_HEADER_VALUES, SHEET_MIMETYPE } from "../../serverConfig";
|
||||
import z from "zod";
|
||||
|
||||
const SCOPES = ["https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.file"]
|
||||
const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json');
|
||||
const MAX_RETRY = 3;
|
||||
|
||||
export const LogRowDataSchema = z.object({
|
||||
timestamp: z.string(),
|
||||
meetingDate: z.string(),
|
||||
title: z.string(),
|
||||
matchedCompanyName: z.string(),
|
||||
ownerName: z.string(),
|
||||
meetingUrl: z.string(),
|
||||
documentUrl: z.string(),
|
||||
hubspotUrl: z.string(),
|
||||
});
|
||||
|
||||
export type LogRowData = z.infer<typeof LogRowDataSchema>
|
||||
|
||||
export const googleDriveController = {
|
||||
getAuth: async():Promise<any> => {
|
||||
const auth = await new google.auth.GoogleAuth({
|
||||
keyFile: CREDENTIALS_PATH,
|
||||
scopes: SCOPES,
|
||||
});
|
||||
return auth;
|
||||
getAuth: async (): Promise<any> => {
|
||||
try {
|
||||
const credentials = JSON.parse(process.env.SEARVICE_ACCOUNT_CREDENTIALS || "{}");
|
||||
console.log(credentials)
|
||||
const auth = await new google.auth.GoogleAuth({
|
||||
credentials: credentials,
|
||||
scopes: SCOPES,
|
||||
});
|
||||
if (!auth) return null;
|
||||
return auth;
|
||||
} catch (error) {
|
||||
console.error("Error obtaining Google Auth:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
checkConnection: async() => {
|
||||
const auth = await googleDriveController.getAuth();
|
||||
getDriveClient: (auth: any): drive_v3.Drive => {
|
||||
// console.log("Google Drive client authenticated.");
|
||||
const drive = google.drive({ version: "v3", auth: auth});
|
||||
const folder = '1cCDJKusfrlDrJe2yHCR8pCHJXRqX-4Hw';
|
||||
const res = await drive.files.list({
|
||||
q: `'${folder}' in parents`,
|
||||
const drive = google.drive({ version: "v3", auth: auth });
|
||||
return drive;
|
||||
},
|
||||
getSheetsClient: (auth: any): sheets_v4.Sheets => {
|
||||
const sheets = google.sheets({ version: "v4", auth: auth });
|
||||
return sheets;
|
||||
},
|
||||
getDocsClient: (auth: any): docs_v1.Docs => {
|
||||
const docs = google.docs({ version: "v1", auth: auth });
|
||||
return docs;
|
||||
},
|
||||
|
||||
uploadFile: async (driveClient: drive_v3.Drive, filePath: string, folderId: string, fileName: string): Promise<any> => {
|
||||
try {
|
||||
console.log("Uploading file to Google Drive:", filePath);
|
||||
const response = await driveClient.files.create({
|
||||
requestBody: {
|
||||
name: fileName,
|
||||
parents: [folderId],
|
||||
},
|
||||
media: {
|
||||
mimeType: "application/zip",
|
||||
body: fs.createReadStream(filePath),
|
||||
},
|
||||
});
|
||||
console.log("File uploaded, Id:", response.data.id);
|
||||
fs.unlinkSync(filePath);
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
fs.unlinkSync(filePath);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getFolderId: async (driveClient: drive_v3.Drive, folderId: string, fileName: string): Promise<string | null> => {
|
||||
try {
|
||||
const existsFolderId = await googleDriveController.searchFileIdByFileName(driveClient, folderId, fileName);
|
||||
if(existsFolderId) return existsFolderId;
|
||||
console.log('=== Create New Folder ===')
|
||||
const newFolderId = googleDriveController.createNewFile(driveClient, folderId, fileName, FOLDER_MIMETYPE);
|
||||
if(!newFolderId) return null;
|
||||
return newFolderId;
|
||||
} catch (error) {
|
||||
console.error('Error searching files:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
searchFileIdByFileName: async (driveClient: drive_v3.Drive, folderId: string, fileName: string): Promise<string | null> => {
|
||||
try {
|
||||
const params = googleDriveController.getSearchFileParamsByDebugMode(folderId);
|
||||
const res = await driveClient.files.list(params);
|
||||
console.log("Files:");
|
||||
console.log(res.data.files);
|
||||
if(!res.data.files) return null;
|
||||
|
||||
for(const file of res.data.files) {
|
||||
if(fileName === file.name) {
|
||||
if(!file.id) return null;
|
||||
return file.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error searching files:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSearchFileParamsByDebugMode: (folderId: string): drive_v3.Params$Resource$Files$List => {
|
||||
if(DEBUG) {
|
||||
return {
|
||||
corpora: 'user',
|
||||
q: `'${folderId}' in parents`,
|
||||
pageSize: 10,
|
||||
fields: "files(id, name)",
|
||||
includeItemsFromAllDrives: true,
|
||||
includeTeamDriveItems: true,
|
||||
supportsAllDrives: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
corpora: 'drive',
|
||||
driveId: process.env.GOOGLE_DRIVE_FOLDER_ID,
|
||||
q: `'${folderId}' in parents`,
|
||||
pageSize: 10,
|
||||
fields: "files(id, name)",
|
||||
});
|
||||
console.log("Files:");
|
||||
console.log(res.data.files);
|
||||
includeItemsFromAllDrives: true,
|
||||
includeTeamDriveItems: true,
|
||||
supportsAllDrives: true
|
||||
}
|
||||
},
|
||||
uploadFile: async() => {
|
||||
createNewFile: async (driveClient: drive_v3.Drive, folderId: string, fileName: string, mimeType: string): Promise<string | null> => {
|
||||
try {
|
||||
const requestBody = {
|
||||
name: fileName,
|
||||
parents: [folderId], // 作成したフォルダのIDを指定
|
||||
mimeType: mimeType,
|
||||
};
|
||||
|
||||
const file = await driveClient.files.create({
|
||||
requestBody,
|
||||
// fields: 'id',
|
||||
});
|
||||
|
||||
console.log('File Id:', file.data);
|
||||
if (!file.data.id) return null;
|
||||
return file.data.id;
|
||||
} catch (error) {
|
||||
console.error('Error creating file:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// CAUTION
|
||||
deleteFile: async (driveClient: drive_v3.Drive, fileId: string) => {
|
||||
try {
|
||||
const body = { trashed: true }
|
||||
const response = await driveClient.files.update({
|
||||
fileId: fileId,
|
||||
requestBody: body,
|
||||
});
|
||||
console.log('File deleted:', response.data);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
},
|
||||
addContentToDocs: async (docsClient: docs_v1.Docs, documentId: string, content: string): Promise<boolean> => {
|
||||
try {
|
||||
const requestBody: docs_v1.Schema$BatchUpdateDocumentRequest = {
|
||||
requests: [
|
||||
{
|
||||
insertText: {
|
||||
text: content,
|
||||
location: {
|
||||
index: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
const response = await docsClient.documents.batchUpdate({
|
||||
documentId: documentId,
|
||||
requestBody: requestBody,
|
||||
});
|
||||
console.log('Content added to document:', response.data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error adding content to document:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
createNewFile: async() => {
|
||||
|
||||
getLogSheetId: async (driveClient: drive_v3.Drive, sheetsClient: sheets_v4.Sheets, folderId: string, fileName: string): Promise<string | null> => {
|
||||
try {
|
||||
const existsSheetId = await googleDriveController.searchFileIdByFileName(driveClient, folderId, fileName);
|
||||
if(existsSheetId) return existsSheetId;
|
||||
console.log('=== Create New Sheet ===')
|
||||
const newSheetId = await googleDriveController.createNewFile(driveClient, folderId, fileName, SHEET_MIMETYPE);
|
||||
if(!newSheetId) return null;
|
||||
//
|
||||
await googleDriveController.insertRowToSheet(sheetsClient, newSheetId, ['※シート名変更厳禁']);
|
||||
await googleDriveController.insertRowToSheet(sheetsClient, newSheetId, LOG_SHEET_HEADER_VALUES);
|
||||
return newSheetId;
|
||||
} catch (error) {
|
||||
console.error('Error searching files:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
insertRowToSheet: async (sheetsClient: sheets_v4.Sheets, sheetId: string, rowData: string[] ): Promise<boolean> => {
|
||||
try {
|
||||
const body = {
|
||||
values: [rowData]
|
||||
}
|
||||
const params: sheets_v4.Params$Resource$Spreadsheets$Values$Append = {
|
||||
spreadsheetId: sheetId,
|
||||
range: 'Sheet1',
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
insertDataOption: 'INSERT_ROWS',
|
||||
requestBody: body,
|
||||
}
|
||||
await sheetsClient.spreadsheets.values.append(params);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { Client } from "@hubspot/api-client";
|
||||
import { AssociationSpecAssociationCategoryEnum } from "@hubspot/api-client/lib/codegen/crm/objects/meetings/models/AssociationSpec";
|
||||
import { PublicAssociationsForObject } from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
|
||||
import z, { email } from "zod";
|
||||
|
||||
const hubspotClient = new Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN });
|
||||
|
||||
export const CompanySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
export const OwnerSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().optional().default(''),
|
||||
});
|
||||
|
||||
export type Company = z.infer<typeof CompanySchema>;
|
||||
export type Owner = z.infer<typeof OwnerSchema>;
|
||||
|
||||
export const hubspotController = {
|
||||
check: async() => {
|
||||
const response = await hubspotClient.crm.companies.getAll();
|
||||
console.log(response.length);
|
||||
},
|
||||
getCompanies: async(): Promise<Company[] | null> => {
|
||||
try {
|
||||
const allCompanies: Company[] = [];
|
||||
const limit = 100;
|
||||
let after: string | undefined = undefined;
|
||||
for(let i = 0; i < 1000; i++) {
|
||||
console.log(`Fetching companies, iteration ${i+1}`);
|
||||
const response = await hubspotClient.crm.companies.basicApi.getPage(limit, after);
|
||||
// console.log(response.results);
|
||||
const companies: Company[] = response.results.map((company) => CompanySchema.parse({
|
||||
id: company.id,
|
||||
name: company.properties.name,
|
||||
}));
|
||||
allCompanies.push(...companies);
|
||||
|
||||
if(response.paging && response.paging.next && response.paging.next.after) {
|
||||
after = response.paging.next.after;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return allCompanies;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getOwners: async(): Promise<Owner[] | null> => {
|
||||
try {
|
||||
const allOwners: Owner[] = [];
|
||||
const limit = 100;
|
||||
let after: string | undefined = undefined;
|
||||
for(let i = 0; i < 1000; i++) {
|
||||
console.log(`Fetching owners, iteration ${i+1}`);
|
||||
const response = await hubspotClient.crm.owners.ownersApi.getPage(undefined,after,limit);
|
||||
// console.log(response.results);
|
||||
|
||||
const owners: Owner[] = response.results.map((owner) => OwnerSchema.parse({
|
||||
id: owner.id,
|
||||
email: owner.email,
|
||||
}));
|
||||
allOwners.push(...owners);
|
||||
|
||||
if(response.paging && response.paging.next && response.paging.next.after) {
|
||||
after = response.paging.next.after;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return allOwners;
|
||||
} catch (error) {
|
||||
console.error("Error fetching owners:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
createMeetingLog: async(companyId: string, title: string, userId: string | null, minutes: string, startsAt: string, endsAt: string ): Promise<boolean> => {
|
||||
try {
|
||||
// 改行コードを変換
|
||||
const minutes_html = minutes.replace("\n", "<br>")
|
||||
const associations: PublicAssociationsForObject[] = [{
|
||||
types: [
|
||||
{associationCategory: AssociationSpecAssociationCategoryEnum.HubspotDefined, associationTypeId: 188},
|
||||
],
|
||||
to: {id: companyId},
|
||||
}];
|
||||
|
||||
const properties = {
|
||||
hs_timestamp: startsAt,
|
||||
hs_meeting_title: title,
|
||||
hubspot_owner_id: userId || '',
|
||||
hs_meeting_body: minutes_html,
|
||||
hs_meeting_start_time: startsAt,
|
||||
hs_meeting_end_time: endsAt,
|
||||
}
|
||||
|
||||
const result = await hubspotClient.crm.objects.meetings.basicApi.create({
|
||||
associations: associations,
|
||||
properties: properties,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error creating HubSpot meeting log:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
searchOwnerIdByEmail: (email: string, owners: Owner[]): string | null => {
|
||||
for(const owner of owners) {
|
||||
if(email === owner.email) return owner.id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
import z from "zod";
|
||||
import { aiController } from "./ai";
|
||||
import { dateController } from "./date";
|
||||
import { googleDriveController } from "./googleDrive";
|
||||
import { googleDriveController, LogRowData, LogRowDataSchema } from "./googleDrive";
|
||||
import { fileController } from "./file";
|
||||
import path, { join } from "path";
|
||||
import fs from "fs";
|
||||
import { createCustomError, responseError } from "./error";
|
||||
import { storageController } from "./storage";
|
||||
import { CLOUD_STORAGE_MASTER_FOLDER_NAME, DATE_FORMAT, DATETIME_FORMAT, DOCUMENT_MIMETYPE, OWNERS_FILE_NAME, Y_FORMAT, YM_FORMAT } from "../../serverConfig";
|
||||
import { hubspotController, OwnerSchema } from "./hubspot";
|
||||
import { fuzzyMatchController } from "./fuzzyMatch";
|
||||
|
||||
const VideoInfoSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
|
|
@ -26,32 +34,101 @@ export const MiiTelWebhookSchema = z.object({
|
|||
|
||||
// export type MiiTelWebhook = z.infer<typeof MiiTelWebhookSchema>;
|
||||
|
||||
export const processRequest = async(videoInfo: VideoInfo) => {
|
||||
const videoId = videoInfo.id;
|
||||
const title = videoInfo.title;
|
||||
const startsAt = videoInfo.starts_at;
|
||||
const endsAt = videoInfo.ends_at;
|
||||
const accessPermission = videoInfo.access_permission;
|
||||
|
||||
const host_id = videoInfo.host.login_id;
|
||||
const host_name = videoInfo.host.user_name;
|
||||
|
||||
const speechRecognition = videoInfo.speech_recognition.raw;
|
||||
|
||||
console.log(startsAt);
|
||||
const jstStartsAt = dateController.convertToJst(startsAt);
|
||||
const jstEndsAt = dateController.convertToJst(endsAt);
|
||||
|
||||
googleDriveController.checkConnection();
|
||||
// console.log(dateController.getFormattedDate(startsAtJst, "yyyy/MM/dd hh:mm:ss"));
|
||||
// console.log(endsAt);
|
||||
// console.log("Processing video:", host_id, host_name, title);
|
||||
if(accessPermission !== "EVERYONE" || !title.includes("様") || title.includes("社内")) return;
|
||||
|
||||
const GOOGLE_DRIVE_FOLDER_ID = process.env.GOOGLE_DRIVE_FOLDER_ID || '';
|
||||
const MIITEL_REQUEST_LOG_FOLDER_ID = process.env.MIITEL_REQUEST_LOG_FOLDER_ID || '';
|
||||
const MINUTES_CREATION_HISTORY_FOLDER_ID = process.env.MINUTES_CREATION_HISTORY_FOLDER_ID || '';
|
||||
const MIITEL_URL = process.env.MIITEL_URL || '';
|
||||
const HUBSPOT_COMPANY_URL = process.env.HUBSPOT_COMPANY_URL || '';
|
||||
|
||||
|
||||
// Save Request Log to Google Drive
|
||||
// const minute = await aiController.generateMinutes(speechRecognition);
|
||||
// console.log(minute);
|
||||
const FILE_PATH = join(__dirname, "../files/");
|
||||
|
||||
};
|
||||
export const processRequest = async (videoInfo: VideoInfo) => {
|
||||
try {
|
||||
const videoId = videoInfo.id;
|
||||
const title = videoInfo.title;
|
||||
const startsAt = videoInfo.starts_at;
|
||||
const endsAt = videoInfo.ends_at;
|
||||
const accessPermission = videoInfo.access_permission;
|
||||
|
||||
const hostId = videoInfo.host.login_id;
|
||||
const hostName = videoInfo.host.user_name;
|
||||
|
||||
const speechRecognition = videoInfo.speech_recognition.raw;
|
||||
|
||||
const jstStartsAt = dateController.convertToJst(startsAt);
|
||||
const jstEndsAt = dateController.convertToJst(endsAt);
|
||||
const fileName = fileController.createMinutesFileName(title, hostName, jstStartsAt);
|
||||
const videoUrl = `${MIITEL_URL}app/video/${videoId}`;
|
||||
|
||||
if (accessPermission !== "EVERYONE" || !title.includes("様") || title.includes("社内")) return;
|
||||
|
||||
//
|
||||
const googleAuth = await googleDriveController.getAuth();
|
||||
const driveClient = googleDriveController.getDriveClient(googleAuth);
|
||||
const docsClient = googleDriveController.getDocsClient(googleAuth);
|
||||
const sheetsClient = googleDriveController.getSheetsClient(googleAuth);
|
||||
|
||||
// ===== Save Request Log to Google Drive =====
|
||||
if (!fs.existsSync(FILE_PATH)) fs.mkdirSync(FILE_PATH, { recursive: true });
|
||||
const outputPath = path.join(FILE_PATH, fileName + '.zip');
|
||||
await fileController.createZip(videoInfo, outputPath, fileName);
|
||||
|
||||
const logFileId = await googleDriveController.uploadFile(driveClient, outputPath, MIITEL_REQUEST_LOG_FOLDER_ID, fileName + '.zip');
|
||||
if(!logFileId) throw createCustomError("UPLOAD_LOG_FAILED");
|
||||
|
||||
// ===== Generate Minutes =====
|
||||
const minutes = await aiController.generateMinutes(speechRecognition);
|
||||
console.log(minutes);
|
||||
if (!minutes) throw createCustomError("AI_GENERATION_FAILED");
|
||||
let content = `会議履歴URL:${videoUrl}\n`;
|
||||
content += `担当者:${hostName}\n\n`;
|
||||
content += minutes;
|
||||
|
||||
|
||||
// ===== Upload To Google Drive =====
|
||||
const documentId = await googleDriveController.createNewFile(driveClient, GOOGLE_DRIVE_FOLDER_ID, title, DOCUMENT_MIMETYPE);
|
||||
if (!documentId) throw createCustomError("UPLOAD_MINUTES_FAILED");
|
||||
const result = await googleDriveController.addContentToDocs(docsClient, documentId, minutes);
|
||||
if(!result) throw createCustomError("UPLOAD_MINUTES_FAILED");
|
||||
|
||||
// ===== Create Meeting Log at Hubspot =====
|
||||
const ownersJson = await storageController.loadJsonFromGCS(CLOUD_STORAGE_MASTER_FOLDER_NAME, OWNERS_FILE_NAME);
|
||||
if(!ownersJson) throw createCustomError("GET_OWNERS_FAILED");
|
||||
const parsedOwners = z.array(OwnerSchema).safeParse(JSON.parse(ownersJson));
|
||||
if(!parsedOwners.success) throw createCustomError("ZOD_FAILED");
|
||||
const ownerId = hubspotController.searchOwnerIdByEmail(hostId, parsedOwners.data);
|
||||
|
||||
|
||||
const companyName = fileController.extractCompanyNameFromTitle(title);
|
||||
const matchedCompany = await fuzzyMatchController.searchMatchedCompany(companyName);
|
||||
if(matchedCompany) await hubspotController.createMeetingLog(matchedCompany.id, title, ownerId, minutes, startsAt, endsAt);
|
||||
|
||||
// ===== Apeend Log To SpreadSheet =====
|
||||
const currentYear = dateController.getCurrentJstTime(Y_FORMAT);
|
||||
const yearFileId = await googleDriveController.getFolderId(driveClient, MINUTES_CREATION_HISTORY_FOLDER_ID, currentYear);
|
||||
if(!yearFileId) throw createCustomError("GET_FOLDER_ID_FAILED");
|
||||
|
||||
const currentYearMonth = dateController.getCurrentJstTime(YM_FORMAT);
|
||||
const sheetId = await googleDriveController.getLogSheetId(driveClient, sheetsClient, yearFileId, currentYearMonth);
|
||||
if(!sheetId) throw createCustomError("GET_SHEET_ID_FAILED");
|
||||
|
||||
const currentJstDateTimeStr = dateController.getCurrentJstTime(DATETIME_FORMAT);
|
||||
const currentJstDateStr = dateController.getCurrentJstTime(DATE_FORMAT);
|
||||
const rowData: LogRowData = LogRowDataSchema.parse({
|
||||
timestamp: currentJstDateTimeStr,
|
||||
meetingDate: currentJstDateStr,
|
||||
title: title,
|
||||
matchedCompanyName: matchedCompany?.name ?? '',
|
||||
ownerName: hostName,
|
||||
meetingUrl: videoUrl,
|
||||
documentUrl: `https://docs.google.com/document/d/${documentId}/edit`,
|
||||
hubspotUrl: matchedCompany ? `${HUBSPOT_COMPANY_URL}/${matchedCompany.id}` : '',
|
||||
});
|
||||
await googleDriveController.insertRowToSheet(sheetsClient, sheetId, Object.values(rowData));
|
||||
return;
|
||||
} catch (error) {
|
||||
responseError(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,19 +1,15 @@
|
|||
import { Storage } from "@google-cloud/storage";
|
||||
import zlib from "zlib";
|
||||
|
||||
const csClient = new Storage({
|
||||
projectId: 'datacom-poc',
|
||||
}
|
||||
);
|
||||
const BUCKET_NAME = "meeting-report-data";
|
||||
const csClient = new Storage({projectId: process.env.PROJECT_ID});
|
||||
const BUCKET_NAME = process.env.CLOUD_STORAGE_BUCKET_NAME || '';
|
||||
const bucket = csClient.bucket(BUCKET_NAME);
|
||||
|
||||
export const storageController = {
|
||||
saveToGCS: async(folder: string, filename: string, text: string) => {
|
||||
const gzipped = zlib.gzipSync(text);
|
||||
const file = bucket.file((`${folder}/${filename}.json.gz`));
|
||||
await file.save(gzipped, {
|
||||
contentType: 'application/gzip',
|
||||
saveToGCS: async(folder: string, filename: string, content: any, contentType: string) => {
|
||||
const file = bucket.file((`${folder}/${filename}`));
|
||||
await file.save(content, {
|
||||
contentType: contentType,
|
||||
})
|
||||
},
|
||||
loadFromGCS: async(folder: string, filename: string): Promise<string | null> => {
|
||||
|
|
@ -26,6 +22,16 @@ export const storageController = {
|
|||
return null;
|
||||
}
|
||||
},
|
||||
loadJsonFromGCS: async(folder: string, filename: string): Promise<string | null> => {
|
||||
const file = bucket.file(`${folder}/${filename}`);
|
||||
// console.log("loading file:", file.name);
|
||||
try {
|
||||
const [data] = await file.download();
|
||||
return data.toString("utf-8");
|
||||
} catch (err: any) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
existsInGCS: async(folder: string, filename: string): Promise<boolean> => {
|
||||
const file = bucket.file((`${folder}/${filename}`));
|
||||
console.log("checking file:", file.name);
|
||||
|
|
|
|||
26
functions/generate_minutes/src/stores/errorCodes.ts
Normal file
26
functions/generate_minutes/src/stores/errorCodes.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// errorDefinitions.ts
|
||||
|
||||
export const ERROR_DEFINITIONS = {
|
||||
ZOD_FAILED: { code: "E1003", message: "zodのチェックが失敗しました", statusCode: -1 },
|
||||
// ログ ZIP の Google Drive アップロード失敗
|
||||
UPLOAD_LOG_FAILED: { code: "E3001", message: "ログファイルのアップロードに失敗しました", statusCode: 500 },
|
||||
|
||||
// AI による議事録生成失敗
|
||||
AI_GENERATION_FAILED: { code: "E2001", message: "AIによる議事録生成に失敗しました", statusCode: 500 },
|
||||
|
||||
// 議事録(Google Docs)の作成/アップロード失敗
|
||||
UPLOAD_MINUTES_FAILED: { code: "E3002", message: "議事録のアップロードに失敗しました", statusCode: 500 },
|
||||
|
||||
// オーナー情報の取得失敗
|
||||
GET_OWNERS_FAILED: { code: "E3003", message: "オーナー情報の取得に失敗しました", statusCode: 500 },
|
||||
GET_COMPANIES_FAILED: { code: "E3004", message: "会社情報の取得に失敗しました", statusCode: 500 },
|
||||
|
||||
// 議事録作成履歴スプレッドシートの取得失敗
|
||||
GET_MINUTES_HISTORY_FAILED: { code: "E3005", message: "議事録作成履歴の取得に失敗しました", statusCode: 500 },
|
||||
|
||||
|
||||
GET_FOLDER_ID_FAILED: { code: "E3006", message: "フォルダID取得に失敗しました", statusCode: 500 },
|
||||
GET_SHEET_ID_FAILED: { code: "E3007", message: "スプレッドシートID取得に失敗しました", statusCode: 500 },
|
||||
} as const;
|
||||
|
||||
export type ErrorKey = keyof typeof ERROR_DEFINITIONS;
|
||||
Loading…
Add table
Add a link
Reference in a new issue