feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode
This commit is contained in:
+222
-11
@@ -2,6 +2,7 @@ import { google, type Auth } from 'googleapis';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { v1 } from '@google-cloud/pubsub';
|
||||
import type { GmailConfig } from '../config/schema.js';
|
||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||
import { parseInterval } from './heartbeat.js';
|
||||
@@ -30,9 +31,7 @@ interface PubSubNotification {
|
||||
historyId: string;
|
||||
}
|
||||
|
||||
// Google Cloud Pub/Sub topic for Gmail push notifications.
|
||||
// This must be pre-configured in Google Cloud Console.
|
||||
const GMAIL_PUBSUB_TOPIC = 'projects/flynn-agent/topics/gmail-push';
|
||||
const DEFAULT_TOPIC_ID = 'gmail-push';
|
||||
|
||||
// Watch expires after ~7 days; renew at 6 days (in ms).
|
||||
const WATCH_RENEWAL_MS = 6 * 24 * 60 * 60 * 1000;
|
||||
@@ -56,7 +55,11 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
private lastHistoryId?: string;
|
||||
private pollTimer?: ReturnType<typeof setInterval>;
|
||||
private watchTimer?: ReturnType<typeof setInterval>;
|
||||
private pullTimer?: ReturnType<typeof setInterval>;
|
||||
private pubsubSubscriber?: v1.SubscriberClient;
|
||||
private pullInFlight = false;
|
||||
private readonly config: NonNullable<GmailConfig>;
|
||||
private googleProjectId?: string;
|
||||
|
||||
constructor(
|
||||
config: NonNullable<GmailConfig>,
|
||||
@@ -82,12 +85,28 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up Gmail push watch (Pub/Sub)
|
||||
// Set up Gmail push watch (Pub/Sub). Polling is always enabled.
|
||||
if (!this.config.disable_push) {
|
||||
try {
|
||||
await this.setupWatch();
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
const hint = this.buildWatchErrorHint(errMsg);
|
||||
console.warn(`GmailWatcher: Watch setup failed (will use polling only) — ${errMsg}${hint}`);
|
||||
}
|
||||
} else {
|
||||
const configured = (this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim();
|
||||
if (configured) {
|
||||
console.log('GmailWatcher: Push disabled (disable_push=true)');
|
||||
}
|
||||
}
|
||||
|
||||
// Set up Pub/Sub pull subscription (optional).
|
||||
try {
|
||||
await this.setupWatch();
|
||||
await this.setupPullSubscription();
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.warn(`GmailWatcher: Watch setup failed (will use polling only) — ${errMsg}`);
|
||||
console.warn(`GmailWatcher: Pull setup failed (will continue without pull) — ${errMsg}`);
|
||||
}
|
||||
|
||||
// Start polling fallback
|
||||
@@ -99,8 +118,23 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
}, pollMs);
|
||||
|
||||
this._status = 'connected';
|
||||
console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`);
|
||||
auditLogger?.systemStart('GmailWatcher', { poll_interval: this.config.poll_interval });
|
||||
|
||||
const modes: string[] = [];
|
||||
const pushConfigured = Boolean((this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim());
|
||||
const pullConfigured = Boolean((this.config.pubsub_subscription_id ?? '').trim());
|
||||
if (pushConfigured && !this.config.disable_push) {modes.push('push');}
|
||||
if (pullConfigured) {modes.push('pull');}
|
||||
modes.push('poll');
|
||||
|
||||
console.log(
|
||||
`GmailWatcher: Connected (${modes.join('+')}, poll_interval=${this.config.poll_interval ?? '300s'}${pullConfigured ? `, pubsub_pull_interval=${this.config.pubsub_pull_interval ?? '60s'}` : ''})`,
|
||||
);
|
||||
auditLogger?.systemStart('GmailWatcher', {
|
||||
modes: modes.join('+'),
|
||||
poll_interval: this.config.poll_interval,
|
||||
pubsub_topic: pushConfigured ? 'configured' : 'none',
|
||||
pubsub_subscription_id: pullConfigured ? 'configured' : 'none',
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
@@ -109,9 +143,21 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
this.pollTimer = undefined;
|
||||
}
|
||||
if (this.watchTimer) {
|
||||
clearTimeout(this.watchTimer);
|
||||
clearInterval(this.watchTimer);
|
||||
this.watchTimer = undefined;
|
||||
}
|
||||
if (this.pullTimer) {
|
||||
clearInterval(this.pullTimer);
|
||||
this.pullTimer = undefined;
|
||||
}
|
||||
if (this.pubsubSubscriber) {
|
||||
try {
|
||||
await this.pubsubSubscriber.close();
|
||||
} catch {
|
||||
// Ignore shutdown errors
|
||||
}
|
||||
this.pubsubSubscriber = undefined;
|
||||
}
|
||||
this.oauth2Client = undefined;
|
||||
this._status = 'disconnected';
|
||||
auditLogger?.systemStop('GmailWatcher');
|
||||
@@ -178,7 +224,10 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
|
||||
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {};
|
||||
const { client_id, client_secret, redirect_uris, project_id } = credentials.installed ?? credentials.web ?? {};
|
||||
if (project_id && typeof project_id === 'string') {
|
||||
this.googleProjectId = project_id;
|
||||
}
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
throw new Error('Invalid credentials file — missing client_id or client_secret');
|
||||
@@ -217,13 +266,24 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
private async setupWatch(): Promise<void> {
|
||||
if (!this.oauth2Client) {return;}
|
||||
|
||||
if (this.watchTimer) {
|
||||
clearInterval(this.watchTimer);
|
||||
this.watchTimer = undefined;
|
||||
}
|
||||
|
||||
const topicName = this.resolvePubSubTopicName();
|
||||
if (!topicName) {
|
||||
// Push notifications are optional; polling is always enabled.
|
||||
return;
|
||||
}
|
||||
|
||||
const gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
|
||||
|
||||
const watchResponse = await gmail.users.watch({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
labelIds: this.config.watch_labels ?? ['INBOX'],
|
||||
topicName: GMAIL_PUBSUB_TOPIC,
|
||||
topicName,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,6 +301,157 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
}, WATCH_RENEWAL_MS);
|
||||
}
|
||||
|
||||
private buildWatchErrorHint(errMsg: string): string {
|
||||
const hints: string[] = [];
|
||||
|
||||
if (errMsg.includes('Invalid topicName')) {
|
||||
hints.push(
|
||||
`Tip: set automation.gmail.pubsub_topic to "projects/${this.googleProjectId ?? '<project-id>'}/topics/${DEFAULT_TOPIC_ID}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (/permission denied|PERMISSION_DENIED/i.test(errMsg)) {
|
||||
hints.push('Tip: ensure Gmail has permission to publish to the Pub/Sub topic (IAM)');
|
||||
}
|
||||
|
||||
hints.push('Tip: if Google cannot reach your gateway, set automation.gmail.pubsub_subscription_id for pull mode');
|
||||
|
||||
return hints.length > 0 ? `\n ${hints.join('\n ')}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Pub/Sub topic resource name for Gmail push notifications.
|
||||
*
|
||||
* Priority:
|
||||
* 1) automation.gmail.pubsub_topic
|
||||
* 2) FLYNN_GMAIL_PUBSUB_TOPIC env var
|
||||
* If neither is provided, push notifications are disabled.
|
||||
*/
|
||||
private resolvePubSubTopicName(): string | null {
|
||||
const configured = this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC;
|
||||
let topic = (configured ?? '').trim();
|
||||
|
||||
if (!topic) {return null;}
|
||||
|
||||
// Allow shorthand: just the topic id (e.g. "gmail-push")
|
||||
if (!topic.includes('/')) {
|
||||
if (!this.googleProjectId) {
|
||||
throw new Error(
|
||||
`pubsub_topic '${topic}' must be fully qualified (projects/<project-id>/topics/<topic>) because project_id was not found in credentials`,
|
||||
);
|
||||
}
|
||||
topic = `projects/${this.googleProjectId}/topics/${topic}`;
|
||||
}
|
||||
|
||||
const isValid = /^projects\/[^/]+\/topics\/[^/]+$/.test(topic);
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
`Invalid pubsub_topic '${topic}'. Expected: projects/<project-id>/topics/<topic>`,
|
||||
);
|
||||
}
|
||||
|
||||
return topic;
|
||||
}
|
||||
|
||||
private resolvePubSubSubscriptionName(): string | null {
|
||||
let sub = (this.config.pubsub_subscription_id ?? '').trim();
|
||||
if (!sub) {return null;}
|
||||
|
||||
// Allow shorthand: just the subscription id (e.g. "gmail-pull")
|
||||
if (!sub.includes('/')) {
|
||||
if (!this.googleProjectId) {
|
||||
throw new Error(
|
||||
`pubsub_subscription_id '${sub}' must be fully qualified (projects/<project-id>/subscriptions/<subscription>) because project_id was not found in credentials`,
|
||||
);
|
||||
}
|
||||
sub = `projects/${this.googleProjectId}/subscriptions/${sub}`;
|
||||
}
|
||||
|
||||
const isValid = /^projects\/[^/]+\/subscriptions\/[^/]+$/.test(sub);
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
`Invalid pubsub_subscription_id '${sub}'. Expected: projects/<project-id>/subscriptions/<subscription>`,
|
||||
);
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
private async setupPullSubscription(): Promise<void> {
|
||||
const subscriptionName = this.resolvePubSubSubscriptionName();
|
||||
if (!subscriptionName) {return;}
|
||||
|
||||
if (this.pullTimer) {
|
||||
clearInterval(this.pullTimer);
|
||||
this.pullTimer = undefined;
|
||||
}
|
||||
|
||||
const pullMs = parseInterval(this.config.pubsub_pull_interval ?? '60s');
|
||||
|
||||
// Kick once immediately, then on interval.
|
||||
await this.pullSubscriptionMessages().catch((err) => {
|
||||
console.error('GmailWatcher: Pub/Sub pull error —', err instanceof Error ? err.message : err);
|
||||
});
|
||||
|
||||
this.pullTimer = setInterval(() => {
|
||||
this.pullSubscriptionMessages().catch((err) => {
|
||||
console.error('GmailWatcher: Pub/Sub pull error —', err instanceof Error ? err.message : err);
|
||||
});
|
||||
}, pullMs);
|
||||
|
||||
console.log(
|
||||
`GmailWatcher: Pull enabled (subscription=${subscriptionName}, interval=${this.config.pubsub_pull_interval ?? '60s'})`,
|
||||
);
|
||||
}
|
||||
|
||||
private async getSubscriberClient(): Promise<v1.SubscriberClient> {
|
||||
if (this.pubsubSubscriber) {return this.pubsubSubscriber;}
|
||||
const mod = await import('@google-cloud/pubsub');
|
||||
this.pubsubSubscriber = new mod.v1.SubscriberClient();
|
||||
return this.pubsubSubscriber;
|
||||
}
|
||||
|
||||
private async pullSubscriptionMessages(): Promise<void> {
|
||||
const subscription = this.resolvePubSubSubscriptionName();
|
||||
if (!subscription) {return;}
|
||||
if (this.pullInFlight) {return;}
|
||||
this.pullInFlight = true;
|
||||
|
||||
try {
|
||||
const client = await this.getSubscriberClient();
|
||||
const maxMessages = this.config.pubsub_max_messages ?? 10;
|
||||
|
||||
const [response] = await client.pull({
|
||||
subscription,
|
||||
maxMessages,
|
||||
});
|
||||
|
||||
const received = response.receivedMessages ?? [];
|
||||
if (received.length === 0) {return;}
|
||||
|
||||
const ackIds: string[] = [];
|
||||
for (const receivedMessage of received) {
|
||||
const ackId = receivedMessage.ackId;
|
||||
const data = receivedMessage.message?.data;
|
||||
if (!ackId || !data) {continue;}
|
||||
|
||||
const base64 = Buffer.from(data as Uint8Array).toString('base64');
|
||||
try {
|
||||
await this.handlePushNotification(base64);
|
||||
ackIds.push(ackId);
|
||||
} catch {
|
||||
// If processing fails, leave message unacked for retry.
|
||||
}
|
||||
}
|
||||
|
||||
if (ackIds.length > 0) {
|
||||
await client.acknowledge({ subscription, ackIds });
|
||||
}
|
||||
} finally {
|
||||
this.pullInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll Gmail History API for new messages since lastHistoryId.
|
||||
* Fallback mechanism when Pub/Sub push is not available.
|
||||
|
||||
Reference in New Issue
Block a user