334 lines
9.6 KiB
JavaScript
Executable File
334 lines
9.6 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/*
|
|
Google Workspace helper CLI
|
|
Commands:
|
|
whoami
|
|
send --to <email> --subject <text> --body <text> [--html] [--attach <file>]
|
|
search-mail --query <gmail query> [--max 10]
|
|
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
|
|
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--calendar primary]
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const DEFAULT_SUBJECT = process.env.GW_IMPERSONATE || 'stefano@fiorinis.com';
|
|
const DEFAULT_KEY_CANDIDATES = [
|
|
process.env.GW_KEY_PATH,
|
|
path.join(process.env.HOME || '', '.openclaw/workspace/.clawdbot/credentials/google-workspace/service-account.json'),
|
|
path.join(process.env.HOME || '', '.clawdbot/credentials/google-workspace/service-account.json'),
|
|
].filter(Boolean);
|
|
|
|
const SCOPES = [
|
|
'https://www.googleapis.com/auth/gmail.send',
|
|
'https://www.googleapis.com/auth/gmail.compose',
|
|
'https://www.googleapis.com/auth/gmail.readonly',
|
|
'https://www.googleapis.com/auth/calendar',
|
|
'https://www.googleapis.com/auth/calendar.events',
|
|
];
|
|
|
|
function resolveKeyPath() {
|
|
for (const p of DEFAULT_KEY_CANDIDATES) {
|
|
if (p && fs.existsSync(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { _: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const token = argv[i];
|
|
if (token.startsWith('--')) {
|
|
const key = token.slice(2);
|
|
const next = argv[i + 1];
|
|
if (!next || next.startsWith('--')) {
|
|
out[key] = true;
|
|
} else {
|
|
if (Object.hasOwn(out, key)) {
|
|
out[key] = Array.isArray(out[key]) ? out[key].concat(next) : [out[key], next];
|
|
} else {
|
|
out[key] = next;
|
|
}
|
|
i++;
|
|
}
|
|
} else {
|
|
out._.push(token);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function usage() {
|
|
console.log(`Google Workspace CLI\n
|
|
Env (optional):
|
|
GW_IMPERSONATE user to impersonate (default: ${DEFAULT_SUBJECT})
|
|
GW_KEY_PATH service-account key path
|
|
|
|
Commands:
|
|
whoami
|
|
send --to <email> --subject <text> --body <text> [--html] [--attach <file>]
|
|
search-mail --query <gmail query> [--max 10]
|
|
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
|
|
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--calendar primary]
|
|
`);
|
|
}
|
|
|
|
function assertRequired(opts, required) {
|
|
const missing = required.filter((k) => !opts[k]);
|
|
if (missing.length) {
|
|
throw new Error(`Missing required options: ${missing.map((m) => `--${m}`).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
function toArray(value) {
|
|
if (value == null || value === false) return [];
|
|
return Array.isArray(value) ? value : [value];
|
|
}
|
|
|
|
function getAttachmentContentType(filename) {
|
|
const ext = path.extname(filename).toLowerCase();
|
|
if (ext === '.pdf') return 'application/pdf';
|
|
if (ext === '.txt') return 'text/plain; charset="UTF-8"';
|
|
if (ext === '.html' || ext === '.htm') return 'text/html; charset="UTF-8"';
|
|
if (ext === '.json') return 'application/json';
|
|
return 'application/octet-stream';
|
|
}
|
|
|
|
function wrapBase64(base64) {
|
|
return base64.match(/.{1,76}/g)?.join('\r\n') || '';
|
|
}
|
|
|
|
function loadAttachments(attachArg) {
|
|
return toArray(attachArg).map((filePath) => {
|
|
const absolutePath = path.resolve(filePath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
throw new Error(`Attachment file not found: ${absolutePath}`);
|
|
}
|
|
|
|
return {
|
|
filename: path.basename(absolutePath),
|
|
contentType: getAttachmentContentType(absolutePath),
|
|
data: fs.readFileSync(absolutePath).toString('base64'),
|
|
};
|
|
});
|
|
}
|
|
|
|
function makeRawEmail({ from, to, subject, body, isHtml = false, attachments = [] }) {
|
|
const contentType = isHtml ? 'text/html; charset="UTF-8"' : 'text/plain; charset="UTF-8"';
|
|
const normalizedAttachments = attachments.filter(Boolean);
|
|
|
|
let msg;
|
|
if (normalizedAttachments.length === 0) {
|
|
msg = [
|
|
`From: ${from}`,
|
|
`To: ${to}`,
|
|
`Subject: ${subject}`,
|
|
'MIME-Version: 1.0',
|
|
`Content-Type: ${contentType}`,
|
|
'',
|
|
body,
|
|
].join('\r\n');
|
|
} else {
|
|
const boundary = `gw-boundary-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
msg = [
|
|
`From: ${from}`,
|
|
`To: ${to}`,
|
|
`Subject: ${subject}`,
|
|
'MIME-Version: 1.0',
|
|
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
'',
|
|
`--${boundary}`,
|
|
`Content-Type: ${contentType}`,
|
|
'Content-Transfer-Encoding: 7bit',
|
|
'',
|
|
body,
|
|
'',
|
|
...normalizedAttachments.flatMap((attachment) => [
|
|
`--${boundary}`,
|
|
`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`,
|
|
'Content-Transfer-Encoding: base64',
|
|
`Content-Disposition: attachment; filename="${attachment.filename}"`,
|
|
'',
|
|
wrapBase64(attachment.data),
|
|
'',
|
|
]),
|
|
`--${boundary}--`,
|
|
'',
|
|
].join('\r\n');
|
|
}
|
|
|
|
return Buffer.from(msg)
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/g, '');
|
|
}
|
|
|
|
async function getClients() {
|
|
const { google } = require('googleapis');
|
|
const keyPath = resolveKeyPath();
|
|
if (!keyPath) {
|
|
throw new Error('Service account key not found. Set GW_KEY_PATH or place the file in ~/.openclaw/workspace/.clawdbot/credentials/google-workspace/service-account.json');
|
|
}
|
|
|
|
const auth = new google.auth.GoogleAuth({
|
|
keyFile: keyPath,
|
|
scopes: SCOPES,
|
|
clientOptions: { subject: DEFAULT_SUBJECT },
|
|
});
|
|
|
|
const authClient = await auth.getClient();
|
|
const gmail = google.gmail({ version: 'v1', auth: authClient });
|
|
const calendar = google.calendar({ version: 'v3', auth: authClient });
|
|
|
|
return { gmail, calendar, keyPath };
|
|
}
|
|
|
|
async function cmdWhoami(clients) {
|
|
const profile = await clients.gmail.users.getProfile({ userId: 'me' });
|
|
console.log(JSON.stringify({
|
|
impersonating: DEFAULT_SUBJECT,
|
|
keyPath: clients.keyPath,
|
|
profile: profile.data,
|
|
}, null, 2));
|
|
}
|
|
|
|
async function cmdSend(clients, opts) {
|
|
assertRequired(opts, ['to', 'subject', 'body']);
|
|
const raw = makeRawEmail({
|
|
from: DEFAULT_SUBJECT,
|
|
to: opts.to,
|
|
subject: opts.subject,
|
|
body: opts.body,
|
|
isHtml: !!opts.html,
|
|
attachments: loadAttachments(opts.attach),
|
|
});
|
|
|
|
const res = await clients.gmail.users.messages.send({
|
|
userId: 'me',
|
|
requestBody: { raw },
|
|
});
|
|
|
|
console.log(JSON.stringify({ ok: true, id: res.data.id, threadId: res.data.threadId }, null, 2));
|
|
}
|
|
|
|
async function cmdSearchMail(clients, opts) {
|
|
assertRequired(opts, ['query']);
|
|
const maxResults = Math.max(1, Math.min(50, Number(opts.max || 10)));
|
|
|
|
const list = await clients.gmail.users.messages.list({
|
|
userId: 'me',
|
|
q: opts.query,
|
|
maxResults,
|
|
});
|
|
|
|
const ids = (list.data.messages || []).map((m) => m.id).filter(Boolean);
|
|
const out = [];
|
|
for (const id of ids) {
|
|
const msg = await clients.gmail.users.messages.get({
|
|
userId: 'me',
|
|
id,
|
|
format: 'metadata',
|
|
metadataHeaders: ['From', 'To', 'Subject', 'Date'],
|
|
});
|
|
const headers = Object.fromEntries((msg.data.payload?.headers || []).map((h) => [h.name, h.value]));
|
|
out.push({
|
|
id,
|
|
threadId: msg.data.threadId,
|
|
snippet: msg.data.snippet,
|
|
from: headers.From,
|
|
to: headers.To,
|
|
subject: headers.Subject,
|
|
date: headers.Date,
|
|
});
|
|
}
|
|
|
|
console.log(JSON.stringify({ count: out.length, messages: out }, null, 2));
|
|
}
|
|
|
|
async function cmdSearchCalendar(clients, opts) {
|
|
const maxResults = Math.max(1, Math.min(50, Number(opts.max || 10)));
|
|
|
|
const res = await clients.calendar.events.list({
|
|
calendarId: opts.calendar || 'primary',
|
|
q: opts.query || undefined,
|
|
timeMin: opts.timeMin,
|
|
timeMax: opts.timeMax,
|
|
singleEvents: true,
|
|
orderBy: 'startTime',
|
|
maxResults,
|
|
});
|
|
|
|
const events = (res.data.items || []).map((e) => ({
|
|
id: e.id,
|
|
summary: e.summary,
|
|
status: e.status,
|
|
start: e.start,
|
|
end: e.end,
|
|
location: e.location,
|
|
hangoutLink: e.hangoutLink,
|
|
}));
|
|
|
|
console.log(JSON.stringify({ count: events.length, events }, null, 2));
|
|
}
|
|
|
|
async function cmdCreateEvent(clients, opts) {
|
|
assertRequired(opts, ['summary', 'start', 'end']);
|
|
const tz = opts.timeZone || 'America/Chicago';
|
|
|
|
const event = {
|
|
summary: opts.summary,
|
|
description: opts.description,
|
|
location: opts.location,
|
|
start: { dateTime: opts.start, timeZone: tz },
|
|
end: { dateTime: opts.end, timeZone: tz },
|
|
};
|
|
|
|
const res = await clients.calendar.events.insert({
|
|
calendarId: opts.calendar || 'primary',
|
|
requestBody: event,
|
|
});
|
|
|
|
console.log(JSON.stringify({ ok: true, id: res.data.id, htmlLink: res.data.htmlLink }, null, 2));
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const cmd = args._[0];
|
|
|
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
usage();
|
|
process.exit(0);
|
|
}
|
|
|
|
const clients = await getClients();
|
|
|
|
if (cmd === 'whoami') return await cmdWhoami(clients);
|
|
if (cmd === 'send') return await cmdSend(clients, args);
|
|
if (cmd === 'search-mail') return await cmdSearchMail(clients, args);
|
|
if (cmd === 'search-calendar') return await cmdSearchCalendar(clients, args);
|
|
if (cmd === 'create-event') return await cmdCreateEvent(clients, args);
|
|
|
|
throw new Error(`Unknown command: ${cmd}`);
|
|
} catch (err) {
|
|
console.error(`ERROR: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = {
|
|
getAttachmentContentType,
|
|
loadAttachments,
|
|
main,
|
|
makeRawEmail,
|
|
parseArgs,
|
|
resolveKeyPath,
|
|
toArray,
|
|
wrapBase64,
|
|
};
|