feat: support multiple Gmail accounts for polling (#244)
Add multi-account Gmail polling with per-account seen tracking, updated onboarding flow, and config/env resolution. Based on jasoncarreira's work in #214, rebased onto current main and cleaned up: - parseGmailAccounts() extracted to polling/service.ts with 10 unit tests - Per-account seen email tracking (Map<string, Set<string>>) with legacy migration from single-account format - Onboarding supports multi-select for existing accounts + add new - Config resolution: polling.gmail.accounts > integrations.google.accounts (legacy) > GMAIL_ACCOUNT env (comma-separated) - GoogleAccountConfig type for per-account service selection - Updated docs/configuration.md Closes #214. Written by Cameron ◯ Letta Code "Good artists copy, great artists steal." - Pablo Picasso
This commit is contained in:
@@ -387,15 +387,18 @@ polling:
|
|||||||
intervalMs: 60000 # Check every 60 seconds (default: 60000)
|
intervalMs: 60000 # Check every 60 seconds (default: 60000)
|
||||||
gmail:
|
gmail:
|
||||||
enabled: true
|
enabled: true
|
||||||
account: user@example.com # Gmail account to poll
|
accounts: # Gmail accounts to poll
|
||||||
|
- user@example.com
|
||||||
|
- other@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `polling.enabled` | boolean | auto | Master switch. Defaults to `true` if any sub-config is enabled |
|
| `polling.enabled` | boolean | auto | Master switch. Defaults to `true` if any sub-config is enabled |
|
||||||
| `polling.intervalMs` | number | `60000` | Polling interval in milliseconds |
|
| `polling.intervalMs` | number | `60000` | Polling interval in milliseconds |
|
||||||
| `polling.gmail.enabled` | boolean | auto | Enable Gmail polling. Auto-detected from `account` |
|
| `polling.gmail.enabled` | boolean | auto | Enable Gmail polling. Auto-detected from `account` or `accounts` |
|
||||||
| `polling.gmail.account` | string | - | Gmail account to poll for unread messages |
|
| `polling.gmail.account` | string | - | Gmail account to poll for unread messages |
|
||||||
|
| `polling.gmail.accounts` | string[] | - | Gmail accounts to poll for unread messages |
|
||||||
|
|
||||||
### Legacy config path
|
### Legacy config path
|
||||||
|
|
||||||
@@ -405,7 +408,9 @@ For backward compatibility, Gmail polling can also be configured under `integrat
|
|||||||
integrations:
|
integrations:
|
||||||
google:
|
google:
|
||||||
enabled: true
|
enabled: true
|
||||||
account: user@example.com
|
accounts:
|
||||||
|
- account: user@example.com
|
||||||
|
services: [gmail, calendar]
|
||||||
pollIntervalSec: 60
|
pollIntervalSec: 60
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -415,7 +420,7 @@ The top-level `polling` section takes priority if both are present.
|
|||||||
|
|
||||||
| Env Variable | Polling Config Equivalent |
|
| Env Variable | Polling Config Equivalent |
|
||||||
|--------------|--------------------------|
|
|--------------|--------------------------|
|
||||||
| `GMAIL_ACCOUNT` | `polling.gmail.account` |
|
| `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) |
|
||||||
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
|
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
|
||||||
| `PORT` | `api.port` |
|
| `PORT` | `api.port` |
|
||||||
| `API_HOST` | `api.host` |
|
| `API_HOST` | `api.host` |
|
||||||
@@ -547,7 +552,7 @@ Environment variables override config file values:
|
|||||||
| `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` |
|
| `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` |
|
||||||
| `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` |
|
| `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` |
|
||||||
| `OPENAI_API_KEY` | `transcription.apiKey` |
|
| `OPENAI_API_KEY` | `transcription.apiKey` |
|
||||||
| `GMAIL_ACCOUNT` | `polling.gmail.account` |
|
| `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) |
|
||||||
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
|
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
|
||||||
|
|
||||||
See [SKILL.md](../SKILL.md) for complete environment variable reference.
|
See [SKILL.md](../SKILL.md) for complete environment variable reference.
|
||||||
|
|||||||
@@ -210,16 +210,26 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Polling - top-level polling config (preferred)
|
// Polling - top-level polling config (preferred)
|
||||||
if (config.polling?.gmail?.enabled && config.polling.gmail.account) {
|
if (config.polling?.gmail?.enabled) {
|
||||||
env.GMAIL_ACCOUNT = config.polling.gmail.account;
|
const accounts = config.polling.gmail.accounts !== undefined
|
||||||
|
? config.polling.gmail.accounts
|
||||||
|
: (config.polling.gmail.account ? [config.polling.gmail.account] : []);
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
env.GMAIL_ACCOUNT = accounts.join(',');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.polling?.intervalMs) {
|
if (config.polling?.intervalMs) {
|
||||||
env.POLLING_INTERVAL_MS = String(config.polling.intervalMs);
|
env.POLLING_INTERVAL_MS = String(config.polling.intervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Integrations - Google (legacy path for Gmail polling, lower priority)
|
// Integrations - Google (legacy path for Gmail polling, lower priority)
|
||||||
if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled && config.integrations.google.account) {
|
if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled) {
|
||||||
env.GMAIL_ACCOUNT = config.integrations.google.account;
|
const legacyAccounts = config.integrations.google.accounts
|
||||||
|
? config.integrations.google.accounts.map(a => a.account)
|
||||||
|
: (config.integrations.google.account ? [config.integrations.google.account] : []);
|
||||||
|
if (legacyAccounts.length > 0) {
|
||||||
|
env.GMAIL_ACCOUNT = legacyAccounts.join(',');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!env.POLLING_INTERVAL_MS && config.integrations?.google?.pollIntervalSec) {
|
if (!env.POLLING_INTERVAL_MS && config.integrations?.google?.pollIntervalSec) {
|
||||||
env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000);
|
env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000);
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export interface PollingYamlConfig {
|
|||||||
gmail?: {
|
gmail?: {
|
||||||
enabled?: boolean; // Enable Gmail polling
|
enabled?: boolean; // Enable Gmail polling
|
||||||
account?: string; // Gmail account to poll (e.g., user@example.com)
|
account?: string; // Gmail account to poll (e.g., user@example.com)
|
||||||
|
accounts?: string[]; // Multiple Gmail accounts to poll
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,9 +201,15 @@ export interface DiscordConfig {
|
|||||||
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
|
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GoogleAccountConfig {
|
||||||
|
account: string;
|
||||||
|
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
|
||||||
|
}
|
||||||
|
|
||||||
export interface GoogleConfig {
|
export interface GoogleConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
account?: string;
|
account?: string;
|
||||||
|
accounts?: GoogleAccountConfig[];
|
||||||
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
|
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
|
||||||
pollIntervalSec?: number; // Polling interval in seconds (default: 60)
|
pollIntervalSec?: number; // Polling interval in seconds (default: 60)
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/main.ts
50
src/main.ts
@@ -150,7 +150,7 @@ import { DiscordAdapter } from './channels/discord.js';
|
|||||||
import { GroupBatcher } from './core/group-batcher.js';
|
import { GroupBatcher } from './core/group-batcher.js';
|
||||||
import { CronService } from './cron/service.js';
|
import { CronService } from './cron/service.js';
|
||||||
import { HeartbeatService } from './cron/heartbeat.js';
|
import { HeartbeatService } from './cron/heartbeat.js';
|
||||||
import { PollingService } from './polling/service.js';
|
import { PollingService, parseGmailAccounts } from './polling/service.js';
|
||||||
import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/letta-api.js';
|
import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/letta-api.js';
|
||||||
// Skills are now installed to agent-scoped location after agent creation (see bot.ts)
|
// Skills are now installed to agent-scoped location after agent creation (see bot.ts)
|
||||||
|
|
||||||
@@ -569,24 +569,42 @@ async function main() {
|
|||||||
}
|
}
|
||||||
bot.onTriggerHeartbeat = () => heartbeatService.trigger();
|
bot.onTriggerHeartbeat = () => heartbeatService.trigger();
|
||||||
|
|
||||||
// Per-agent polling
|
// Per-agent polling -- resolve accounts from polling > integrations.google (legacy) > env
|
||||||
const pollConfig = agentConfig.polling || (agentConfig.integrations?.google ? {
|
const pollConfig = (() => {
|
||||||
enabled: agentConfig.integrations.google.enabled,
|
const pollingAccounts = parseGmailAccounts(
|
||||||
intervalMs: (agentConfig.integrations.google.pollIntervalSec || 60) * 1000,
|
agentConfig.polling?.gmail?.accounts || agentConfig.polling?.gmail?.account
|
||||||
gmail: {
|
);
|
||||||
enabled: agentConfig.integrations.google.enabled,
|
const legacyAccounts = (() => {
|
||||||
account: agentConfig.integrations.google.account || '',
|
const legacy = agentConfig.integrations?.google;
|
||||||
},
|
if (legacy?.accounts?.length) {
|
||||||
} : undefined);
|
return parseGmailAccounts(legacy.accounts.map(a => a.account));
|
||||||
|
}
|
||||||
|
return parseGmailAccounts(legacy?.account);
|
||||||
|
})();
|
||||||
|
const envAccounts = parseGmailAccounts(process.env.GMAIL_ACCOUNT);
|
||||||
|
const gmailAccounts = pollingAccounts.length > 0
|
||||||
|
? pollingAccounts
|
||||||
|
: legacyAccounts.length > 0
|
||||||
|
? legacyAccounts
|
||||||
|
: envAccounts;
|
||||||
|
const gmailEnabled = agentConfig.polling?.gmail?.enabled
|
||||||
|
?? agentConfig.integrations?.google?.enabled
|
||||||
|
?? gmailAccounts.length > 0;
|
||||||
|
return {
|
||||||
|
enabled: agentConfig.polling?.enabled ?? gmailEnabled,
|
||||||
|
intervalMs: agentConfig.polling?.intervalMs
|
||||||
|
?? (agentConfig.integrations?.google?.pollIntervalSec
|
||||||
|
? agentConfig.integrations.google.pollIntervalSec * 1000
|
||||||
|
: 60000),
|
||||||
|
gmail: { enabled: gmailEnabled, accounts: gmailAccounts },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
if (pollConfig?.enabled && pollConfig.gmail?.enabled) {
|
if (pollConfig.enabled && pollConfig.gmail.enabled && pollConfig.gmail.accounts.length > 0) {
|
||||||
const pollingService = new PollingService(bot, {
|
const pollingService = new PollingService(bot, {
|
||||||
intervalMs: pollConfig.intervalMs || 60000,
|
intervalMs: pollConfig.intervalMs,
|
||||||
workingDir: globalConfig.workingDir,
|
workingDir: globalConfig.workingDir,
|
||||||
gmail: {
|
gmail: pollConfig.gmail,
|
||||||
enabled: pollConfig.gmail.enabled,
|
|
||||||
account: pollConfig.gmail.account || '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
pollingService.start();
|
pollingService.start();
|
||||||
services.pollingServices.push(pollingService);
|
services.pollingServices.push(pollingService);
|
||||||
|
|||||||
183
src/onboard.ts
183
src/onboard.ts
@@ -154,7 +154,7 @@ interface OnboardConfig {
|
|||||||
discord: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
|
discord: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
|
||||||
|
|
||||||
// Google Workspace (via gog CLI)
|
// Google Workspace (via gog CLI)
|
||||||
google: { enabled: boolean; account?: string; services?: string[] };
|
google: { enabled: boolean; accounts: Array<{ account: string; services: string[] }> };
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
heartbeat: { enabled: boolean; interval?: string };
|
heartbeat: { enabled: boolean; interval?: string };
|
||||||
@@ -751,6 +751,7 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
|
|||||||
|
|
||||||
if (!setupGoogle) {
|
if (!setupGoogle) {
|
||||||
config.google.enabled = false;
|
config.google.enabled = false;
|
||||||
|
config.google.accounts = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,16 +786,19 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
|
|||||||
spinner.stop('Failed to install gog');
|
spinner.stop('Failed to install gog');
|
||||||
p.log.error('Installation failed. Try manually: brew install steipete/tap/gogcli');
|
p.log.error('Installation failed. Try manually: brew install steipete/tap/gogcli');
|
||||||
config.google.enabled = false;
|
config.google.enabled = false;
|
||||||
|
config.google.accounts = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
p.log.info('Install gog manually: brew install steipete/tap/gogcli');
|
p.log.info('Install gog manually: brew install steipete/tap/gogcli');
|
||||||
config.google.enabled = false;
|
config.google.enabled = false;
|
||||||
|
config.google.accounts = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
p.log.info('Install gog manually from: https://gogcli.sh');
|
p.log.info('Install gog manually from: https://gogcli.sh');
|
||||||
config.google.enabled = false;
|
config.google.enabled = false;
|
||||||
|
config.google.accounts = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,6 +840,7 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
|
|||||||
if (!hasCredentials) {
|
if (!hasCredentials) {
|
||||||
p.log.info('Run `gog auth credentials /path/to/client_secret.json` after downloading credentials.');
|
p.log.info('Run `gog auth credentials /path/to/client_secret.json` after downloading credentials.');
|
||||||
config.google.enabled = false;
|
config.google.enabled = false;
|
||||||
|
config.google.accounts = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,60 +865,116 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configuredAccounts = new Map<string, string[]>();
|
||||||
|
for (const entry of config.google.accounts) {
|
||||||
|
configuredAccounts.set(entry.account, entry.services || []);
|
||||||
|
}
|
||||||
|
|
||||||
let selectedAccount: string | undefined;
|
const newAccounts: Array<{ account: string; services: string[] }> = [];
|
||||||
|
let selectedAccounts: string[] = [];
|
||||||
if (accounts.length > 0) {
|
|
||||||
const accountChoice = await p.select({
|
if (accounts.length === 0) {
|
||||||
message: 'Google account',
|
const firstAccount = await addGoogleAccount();
|
||||||
|
if (!firstAccount) {
|
||||||
|
config.google.enabled = false;
|
||||||
|
config.google.accounts = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newAccounts.push(firstAccount);
|
||||||
|
while (true) {
|
||||||
|
const more = await p.confirm({
|
||||||
|
message: 'Add another Google account?',
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (p.isCancel(more)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||||
|
if (!more) break;
|
||||||
|
const added = await addGoogleAccount();
|
||||||
|
if (added) {
|
||||||
|
newAccounts.push(added);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const accountChoices = await p.multiselect({
|
||||||
|
message: 'Google accounts to enable',
|
||||||
options: [
|
options: [
|
||||||
...accounts.map(a => ({ value: a, label: a, hint: 'Existing account' })),
|
...accounts.map(a => ({ value: a, label: a, hint: 'Existing account' })),
|
||||||
{ value: '__new__', label: 'Add new account', hint: 'Authorize another account' },
|
{ value: '__new__', label: 'Add new account', hint: 'Authorize another account' },
|
||||||
],
|
],
|
||||||
initialValue: config.google.account || accounts[0],
|
initialValues: config.google.accounts.map(a => a.account).filter(a => accounts.includes(a)),
|
||||||
|
required: true,
|
||||||
});
|
});
|
||||||
if (p.isCancel(accountChoice)) { p.cancel('Setup cancelled'); process.exit(0); }
|
if (p.isCancel(accountChoices)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||||
|
|
||||||
if (accountChoice === '__new__') {
|
selectedAccounts = (accountChoices as string[]).filter(a => a !== '__new__');
|
||||||
selectedAccount = await addGoogleAccount();
|
if ((accountChoices as string[]).includes('__new__')) {
|
||||||
} else {
|
while (true) {
|
||||||
selectedAccount = accountChoice as string;
|
const added = await addGoogleAccount();
|
||||||
|
if (added) {
|
||||||
|
newAccounts.push(added);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const more = await p.confirm({
|
||||||
|
message: 'Add another Google account?',
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (p.isCancel(more)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||||
|
if (!more) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
selectedAccount = await addGoogleAccount();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAccount) {
|
const allAccounts = Array.from(new Set([
|
||||||
|
...selectedAccounts,
|
||||||
|
...newAccounts.map(a => a.account),
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (allAccounts.length === 0) {
|
||||||
config.google.enabled = false;
|
config.google.enabled = false;
|
||||||
|
config.google.accounts = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select services
|
const newAccountsByEmail = new Map<string, string[]>();
|
||||||
const selectedServices = await p.multiselect({
|
for (const entry of newAccounts) {
|
||||||
message: 'Which Google services do you want to enable?',
|
newAccountsByEmail.set(entry.account, entry.services);
|
||||||
options: GOG_SERVICES.map(s => ({
|
}
|
||||||
value: s,
|
|
||||||
label: s.charAt(0).toUpperCase() + s.slice(1),
|
const finalizedAccounts: Array<{ account: string; services: string[] }> = [];
|
||||||
hint: s === 'gmail' ? 'Read/send emails' :
|
for (const account of allAccounts) {
|
||||||
s === 'calendar' ? 'View/create events' :
|
const presetServices = newAccountsByEmail.get(account) || configuredAccounts.get(account) || ['gmail', 'calendar'];
|
||||||
s === 'drive' ? 'Access files' :
|
const selectedServices = newAccountsByEmail.has(account) ? presetServices : await p.multiselect({
|
||||||
s === 'contacts' ? 'Look up contacts' :
|
message: `Services to enable for ${account}`,
|
||||||
s === 'docs' ? 'Read documents' :
|
options: GOG_SERVICES.map(s => ({
|
||||||
'Read/edit spreadsheets',
|
value: s,
|
||||||
})),
|
label: s.charAt(0).toUpperCase() + s.slice(1),
|
||||||
initialValues: config.google.services || ['gmail', 'calendar'],
|
hint: s === 'gmail' ? 'Read/send emails' :
|
||||||
required: true,
|
s === 'calendar' ? 'View/create events' :
|
||||||
});
|
s === 'drive' ? 'Access files' :
|
||||||
if (p.isCancel(selectedServices)) { p.cancel('Setup cancelled'); process.exit(0); }
|
s === 'contacts' ? 'Look up contacts' :
|
||||||
|
s === 'docs' ? 'Read documents' :
|
||||||
|
'Read/edit spreadsheets',
|
||||||
|
})),
|
||||||
|
initialValues: presetServices,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
if (p.isCancel(selectedServices)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||||
|
finalizedAccounts.push({
|
||||||
|
account,
|
||||||
|
services: selectedServices as string[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
config.google.enabled = true;
|
config.google.enabled = true;
|
||||||
config.google.account = selectedAccount;
|
config.google.accounts = finalizedAccounts;
|
||||||
config.google.services = selectedServices as string[];
|
|
||||||
|
|
||||||
p.log.success(`Google Workspace configured: ${selectedAccount}`);
|
p.log.success(`Google Workspace configured: ${finalizedAccounts.length} account(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addGoogleAccount(): Promise<string | undefined> {
|
async function addGoogleAccount(): Promise<{ account: string; services: string[] } | undefined> {
|
||||||
const email = await p.text({
|
const email = await p.text({
|
||||||
message: 'Google account email',
|
message: 'Google account email',
|
||||||
placeholder: 'you@gmail.com',
|
placeholder: 'you@gmail.com',
|
||||||
@@ -951,7 +1012,7 @@ async function addGoogleAccount(): Promise<string | undefined> {
|
|||||||
|
|
||||||
if (result.status === 0) {
|
if (result.status === 0) {
|
||||||
spinner.stop('Account authorized');
|
spinner.stop('Account authorized');
|
||||||
return email;
|
return { account: email, services: services as string[] };
|
||||||
} else {
|
} else {
|
||||||
spinner.stop('Authorization failed');
|
spinner.stop('Authorization failed');
|
||||||
p.log.error('Failed to authorize account. Try manually: gog auth add ' + email);
|
p.log.error('Failed to authorize account. Try manually: gog auth add ' + email);
|
||||||
@@ -1009,7 +1070,10 @@ function showSummary(config: OnboardConfig): void {
|
|||||||
|
|
||||||
// Google
|
// Google
|
||||||
if (config.google.enabled) {
|
if (config.google.enabled) {
|
||||||
lines.push(`Google: ${config.google.account} (${config.google.services?.join(', ') || 'all'})`);
|
const googleAccounts = config.google.accounts.map(a => (
|
||||||
|
`${a.account} (${a.services?.join(', ') || 'all'})`
|
||||||
|
));
|
||||||
|
lines.push(`Google: ${googleAccounts.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
p.note(lines.join('\n'), 'Configuration');
|
p.note(lines.join('\n'), 'Configuration');
|
||||||
@@ -1257,11 +1321,21 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
|||||||
selfChat: existingConfig.channels.signal?.selfChat ?? true, // Default true
|
selfChat: existingConfig.channels.signal?.selfChat ?? true, // Default true
|
||||||
dmPolicy: existingConfig.channels.signal?.dmPolicy,
|
dmPolicy: existingConfig.channels.signal?.dmPolicy,
|
||||||
},
|
},
|
||||||
google: {
|
google: (() => {
|
||||||
enabled: existingConfig.integrations?.google?.enabled || false,
|
const existingAccounts = existingConfig.integrations?.google?.accounts
|
||||||
account: existingConfig.integrations?.google?.account,
|
? existingConfig.integrations.google.accounts.map(a => ({
|
||||||
services: existingConfig.integrations?.google?.services,
|
account: a.account,
|
||||||
},
|
services: a.services || [],
|
||||||
|
}))
|
||||||
|
: (existingConfig.integrations?.google?.account ? [{
|
||||||
|
account: existingConfig.integrations.google.account,
|
||||||
|
services: existingConfig.integrations.google.services || [],
|
||||||
|
}] : []);
|
||||||
|
return {
|
||||||
|
enabled: (existingConfig.integrations?.google?.enabled || false) || existingAccounts.length > 0,
|
||||||
|
accounts: existingAccounts,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
enabled: existingConfig.features?.heartbeat?.enabled || false,
|
enabled: existingConfig.features?.heartbeat?.enabled || false,
|
||||||
interval: existingConfig.features?.heartbeat?.intervalMin?.toString(),
|
interval: existingConfig.features?.heartbeat?.intervalMin?.toString(),
|
||||||
@@ -1420,7 +1494,9 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
|||||||
config.signal.enabled ? ` ✓ Signal (${formatAccess(config.signal.dmPolicy, config.signal.allowedUsers)})` : ' ✗ Signal',
|
config.signal.enabled ? ` ✓ Signal (${formatAccess(config.signal.dmPolicy, config.signal.allowedUsers)})` : ' ✗ Signal',
|
||||||
'',
|
'',
|
||||||
'Integrations:',
|
'Integrations:',
|
||||||
config.google.enabled ? ` ✓ Google (${config.google.account} - ${config.google.services?.join(', ') || 'all'})` : ' ✗ Google Workspace',
|
config.google.enabled && config.google.accounts.length > 0
|
||||||
|
? ` ✓ Google (${config.google.accounts.map(a => `${a.account} - ${a.services?.join(', ') || 'all'}`).join(', ')})`
|
||||||
|
: ' ✗ Google Workspace',
|
||||||
'',
|
'',
|
||||||
'Features:',
|
'Features:',
|
||||||
config.heartbeat.enabled ? ` ✓ Heartbeat (${config.heartbeat.interval}min)` : ' ✗ Heartbeat',
|
config.heartbeat.enabled ? ` ✓ Heartbeat (${config.heartbeat.interval}min)` : ' ✗ Heartbeat',
|
||||||
@@ -1503,10 +1579,17 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
|||||||
integrations: {
|
integrations: {
|
||||||
google: {
|
google: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
account: config.google.account,
|
accounts: config.google.accounts,
|
||||||
services: config.google.services,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...((() => {
|
||||||
|
const gmailAccounts = config.google.accounts
|
||||||
|
.filter(a => a.services?.includes('gmail'))
|
||||||
|
.map(a => a.account);
|
||||||
|
return gmailAccounts.length > 0 ? {
|
||||||
|
polling: { gmail: { accounts: gmailAccounts } },
|
||||||
|
} : {};
|
||||||
|
})()),
|
||||||
} : {}),
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
44
src/polling/service.test.ts
Normal file
44
src/polling/service.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseGmailAccounts } from './service.js';
|
||||||
|
|
||||||
|
describe('parseGmailAccounts', () => {
|
||||||
|
it('returns empty array for undefined', () => {
|
||||||
|
expect(parseGmailAccounts(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty string', () => {
|
||||||
|
expect(parseGmailAccounts('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses single account string', () => {
|
||||||
|
expect(parseGmailAccounts('user@gmail.com')).toEqual(['user@gmail.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses comma-separated string', () => {
|
||||||
|
expect(parseGmailAccounts('a@gmail.com,b@gmail.com')).toEqual(['a@gmail.com', 'b@gmail.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace', () => {
|
||||||
|
expect(parseGmailAccounts(' a@gmail.com , b@gmail.com ')).toEqual(['a@gmail.com', 'b@gmail.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates accounts', () => {
|
||||||
|
expect(parseGmailAccounts('a@gmail.com,a@gmail.com,b@gmail.com')).toEqual(['a@gmail.com', 'b@gmail.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips empty segments', () => {
|
||||||
|
expect(parseGmailAccounts('a@gmail.com,,b@gmail.com,')).toEqual(['a@gmail.com', 'b@gmail.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts string array', () => {
|
||||||
|
expect(parseGmailAccounts(['a@gmail.com', 'b@gmail.com'])).toEqual(['a@gmail.com', 'b@gmail.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates string array', () => {
|
||||||
|
expect(parseGmailAccounts(['a@gmail.com', 'a@gmail.com'])).toEqual(['a@gmail.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims string array values', () => {
|
||||||
|
expect(parseGmailAccounts([' a@gmail.com ', ' b@gmail.com '])).toEqual(['a@gmail.com', 'b@gmail.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,12 +10,28 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { AgentSession } from '../core/interfaces.js';
|
import type { AgentSession } from '../core/interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Gmail accounts from a string (comma-separated) or string array.
|
||||||
|
* Deduplicates and trims whitespace.
|
||||||
|
*/
|
||||||
|
export function parseGmailAccounts(raw?: string | string[]): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
const values = Array.isArray(raw) ? raw : raw.split(',');
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
}
|
||||||
|
return Array.from(seen);
|
||||||
|
}
|
||||||
|
|
||||||
export interface PollingConfig {
|
export interface PollingConfig {
|
||||||
intervalMs: number; // Polling interval in milliseconds
|
intervalMs: number; // Polling interval in milliseconds
|
||||||
workingDir: string; // For persisting state
|
workingDir: string; // For persisting state
|
||||||
gmail?: {
|
gmail?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
account: string;
|
accounts: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,8 +40,8 @@ export class PollingService {
|
|||||||
private bot: AgentSession;
|
private bot: AgentSession;
|
||||||
private config: PollingConfig;
|
private config: PollingConfig;
|
||||||
|
|
||||||
// Track seen email IDs to detect new emails (persisted to disk)
|
// Track seen email IDs per account to detect new emails (persisted to disk)
|
||||||
private seenEmailIds: Set<string> = new Set();
|
private seenEmailIdsByAccount: Map<string, Set<string>> = new Map();
|
||||||
private seenEmailsPath: string;
|
private seenEmailsPath: string;
|
||||||
|
|
||||||
constructor(bot: AgentSession, config: PollingConfig) {
|
constructor(bot: AgentSession, config: PollingConfig) {
|
||||||
@@ -42,8 +58,28 @@ export class PollingService {
|
|||||||
try {
|
try {
|
||||||
if (existsSync(this.seenEmailsPath)) {
|
if (existsSync(this.seenEmailsPath)) {
|
||||||
const data = JSON.parse(readFileSync(this.seenEmailsPath, 'utf-8'));
|
const data = JSON.parse(readFileSync(this.seenEmailsPath, 'utf-8'));
|
||||||
this.seenEmailIds = new Set(data.ids || []);
|
|
||||||
console.log(`[Polling] Loaded ${this.seenEmailIds.size} seen email IDs`);
|
// New per-account format: { accounts: { "email": { ids: [...] } } }
|
||||||
|
if (data && typeof data === 'object' && data.accounts && typeof data.accounts === 'object') {
|
||||||
|
for (const [account, accountData] of Object.entries(data.accounts)) {
|
||||||
|
const ids = Array.isArray((accountData as { ids?: string[] }).ids)
|
||||||
|
? (accountData as { ids?: string[] }).ids!
|
||||||
|
: [];
|
||||||
|
this.seenEmailIdsByAccount.set(account, new Set(ids));
|
||||||
|
}
|
||||||
|
console.log(`[Polling] Loaded seen email IDs for ${this.seenEmailIdsByAccount.size} account(s)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy single-account format: { ids: [...] }
|
||||||
|
if (data && Array.isArray(data.ids)) {
|
||||||
|
const accounts = this.config.gmail?.accounts || [];
|
||||||
|
const targetAccount = accounts[0];
|
||||||
|
if (targetAccount) {
|
||||||
|
this.seenEmailIdsByAccount.set(targetAccount, new Set(data.ids));
|
||||||
|
console.log(`[Polling] Migrated legacy seen emails to ${targetAccount}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Polling] Failed to load seen emails:', e);
|
console.error('[Polling] Failed to load seen emails:', e);
|
||||||
@@ -55,9 +91,17 @@ export class PollingService {
|
|||||||
*/
|
*/
|
||||||
private saveSeenEmails(): void {
|
private saveSeenEmails(): void {
|
||||||
try {
|
try {
|
||||||
|
const accounts: Record<string, { ids: string[]; updatedAt: string }> = {};
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const [account, ids] of this.seenEmailIdsByAccount.entries()) {
|
||||||
|
accounts[account] = {
|
||||||
|
ids: Array.from(ids),
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
writeFileSync(this.seenEmailsPath, JSON.stringify({
|
writeFileSync(this.seenEmailsPath, JSON.stringify({
|
||||||
ids: Array.from(this.seenEmailIds),
|
accounts,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: now,
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Polling] Failed to save seen emails:', e);
|
console.error('[Polling] Failed to save seen emails:', e);
|
||||||
@@ -74,7 +118,13 @@ export class PollingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledPollers: string[] = [];
|
const enabledPollers: string[] = [];
|
||||||
if (this.config.gmail?.enabled) enabledPollers.push('Gmail');
|
if (this.config.gmail?.enabled) {
|
||||||
|
if (this.config.gmail.accounts.length > 0) {
|
||||||
|
enabledPollers.push(`Gmail (${this.config.gmail.accounts.length} account${this.config.gmail.accounts.length === 1 ? '' : 's'})`);
|
||||||
|
} else {
|
||||||
|
console.log('[Polling] Gmail enabled but no accounts configured');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (enabledPollers.length === 0) {
|
if (enabledPollers.length === 0) {
|
||||||
console.log('[Polling] No pollers enabled');
|
console.log('[Polling] No pollers enabled');
|
||||||
@@ -106,16 +156,21 @@ export class PollingService {
|
|||||||
*/
|
*/
|
||||||
private async poll(): Promise<void> {
|
private async poll(): Promise<void> {
|
||||||
if (this.config.gmail?.enabled) {
|
if (this.config.gmail?.enabled) {
|
||||||
await this.pollGmail();
|
for (const account of this.config.gmail.accounts) {
|
||||||
|
await this.pollGmail(account);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll Gmail for new unread messages
|
* Poll Gmail for new unread messages
|
||||||
*/
|
*/
|
||||||
private async pollGmail(): Promise<void> {
|
private async pollGmail(account: string): Promise<void> {
|
||||||
const account = this.config.gmail?.account;
|
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
|
if (!this.seenEmailIdsByAccount.has(account)) {
|
||||||
|
this.seenEmailIdsByAccount.set(account, new Set());
|
||||||
|
}
|
||||||
|
const seenEmailIds = this.seenEmailIdsByAccount.get(account)!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for unread emails (use longer window to catch any we might have missed)
|
// Check for unread emails (use longer window to catch any we might have missed)
|
||||||
@@ -130,7 +185,7 @@ export class PollingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
console.log(`[Polling] 📧 Gmail check failed: ${result.stderr || 'unknown error'}`);
|
console.log(`[Polling] Gmail check failed for ${account}: ${result.stderr || 'unknown error'}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +202,7 @@ export class PollingService {
|
|||||||
const id = line.split(/\s+/)[0]; // First column is ID
|
const id = line.split(/\s+/)[0]; // First column is ID
|
||||||
if (id) {
|
if (id) {
|
||||||
currentEmailIds.add(id);
|
currentEmailIds.add(id);
|
||||||
if (!this.seenEmailIds.has(id)) {
|
if (!seenEmailIds.has(id)) {
|
||||||
newEmails.push(line);
|
newEmails.push(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,17 +210,17 @@ export class PollingService {
|
|||||||
|
|
||||||
// Add new IDs to seen set (don't replace - we want to remember all seen emails)
|
// Add new IDs to seen set (don't replace - we want to remember all seen emails)
|
||||||
for (const id of currentEmailIds) {
|
for (const id of currentEmailIds) {
|
||||||
this.seenEmailIds.add(id);
|
seenEmailIds.add(id);
|
||||||
}
|
}
|
||||||
this.saveSeenEmails();
|
this.saveSeenEmails();
|
||||||
|
|
||||||
// Only notify if there are NEW emails we haven't seen before
|
// Only notify if there are NEW emails we haven't seen before
|
||||||
if (newEmails.length === 0) {
|
if (newEmails.length === 0) {
|
||||||
console.log(`[Polling] 📧 No new emails (${currentEmailIds.size} unread total)`);
|
console.log(`[Polling] No new emails for ${account} (${currentEmailIds.size} unread total)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Polling] 📧 Found ${newEmails.length} NEW email(s)!`);
|
console.log(`[Polling] Found ${newEmails.length} NEW email(s) for ${account}!`);
|
||||||
|
|
||||||
// Build output with header + new emails only
|
// Build output with header + new emails only
|
||||||
const header = lines[0];
|
const header = lines[0];
|
||||||
@@ -179,7 +234,7 @@ export class PollingService {
|
|||||||
'║ To send a message, use: lettabot-message send --text "..." ║',
|
'║ To send a message, use: lettabot-message send --text "..." ║',
|
||||||
'╚════════════════════════════════════════════════════════════════╝',
|
'╚════════════════════════════════════════════════════════════════╝',
|
||||||
'',
|
'',
|
||||||
`[email] ${newEmails.length} new unread email(s):`,
|
`[email] ${newEmails.length} new unread email(s) for ${account}:`,
|
||||||
'',
|
'',
|
||||||
newEmailsOutput,
|
newEmailsOutput,
|
||||||
'',
|
'',
|
||||||
@@ -189,11 +244,11 @@ export class PollingService {
|
|||||||
const response = await this.bot.sendToAgent(message);
|
const response = await this.bot.sendToAgent(message);
|
||||||
|
|
||||||
// Log response but do NOT auto-deliver (silent mode)
|
// Log response but do NOT auto-deliver (silent mode)
|
||||||
console.log(`[Polling] 📧 Agent finished (SILENT MODE)`);
|
console.log(`[Polling] Agent finished (SILENT MODE)`);
|
||||||
console.log(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`);
|
console.log(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`);
|
||||||
console.log(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`)
|
console.log(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Polling] 📧 Gmail error:', e);
|
console.error(`[Polling] Gmail error for ${account}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user