@chronicle.app/google 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/GoogleApi.d.ts +44 -0
- package/dist/GoogleApi.js +171 -0
- package/dist/GoogleOAuthProvider.d.ts +26 -0
- package/dist/GoogleOAuthProvider.js +47 -0
- package/dist/account.d.ts +9 -0
- package/dist/account.js +14 -0
- package/dist/contacts.d.ts +59 -0
- package/dist/contacts.js +119 -0
- package/dist/gcloud.d.ts +49 -0
- package/dist/gcloud.js +110 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/services.d.ts +30 -0
- package/dist/services.js +28 -0
- package/dist/setup.d.ts +20 -0
- package/dist/setup.js +416 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Louis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @chronicle.app/google
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Google sign-in for Chronicle's Google sources: Gmail (`chronicle extract gmail`) and Google Calendar (`chronicle extract google-calendar`). Chronicle ships no Google OAuth client. You make one in your own Google Cloud project, once, and `chronicle auth login google` walks you through it.
|
|
4
|
+
|
|
5
|
+
## Signing in
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
chronicle auth login google
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The first time, this sets up your client in three steps:
|
|
12
|
+
|
|
13
|
+
1. **A Google Cloud project, with the Gmail and Calendar APIs on.** With [gcloud](https://cloud.google.com/sdk/docs/install) installed, Chronicle creates it for you. Without it, you create it in the browser and paste its ID.
|
|
14
|
+
2. **A consent screen**, made External and published. Published, your sign-in lasts; left in testing, Google signs you out after 7 days. Google doesn't review an app only you use, so it stays unverified, and the sign-in page says so. Click Continue (or Advanced, then Go to Chronicle).
|
|
15
|
+
3. **A Desktop app OAuth client.** The window Google shows after you create it has the client ID and secret. Copy each one into the terminal when it asks. (A path to the client's downloaded JSON file works in place of the ID.)
|
|
16
|
+
|
|
17
|
+
Then you sign in in the browser. The consent page lists each kind of access with its own box, so tick them all.
|
|
18
|
+
|
|
19
|
+
Each step is saved as it finishes, so you can stop and run the command again later. After that, `chronicle auth login google` goes straight to the sign-in.
|
|
20
|
+
|
|
21
|
+
### Several accounts
|
|
22
|
+
|
|
23
|
+
Run `chronicle auth login google` again and sign in with another account. One client works for all your accounts, personal or Workspace. Pick one for a run with `--account`:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
chronicle extract google-calendar --account you@work.example
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Without `--account`, a run uses the account that signed in last. `chronicle auth status google` lists them.
|
|
30
|
+
|
|
31
|
+
A Workspace admin can block apps their organization hasn't approved from Gmail and Drive. If sign-in says access is blocked, ask them to trust your client ID (Admin console, Security, API controls).
|
|
32
|
+
|
|
33
|
+
### More access
|
|
34
|
+
|
|
35
|
+
The first sign-in asks for Gmail and Calendar. `--add` asks for more, keeping what you have, and turns the API on in your project:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
chronicle auth login google --add drive
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Without the walkthrough
|
|
42
|
+
|
|
43
|
+
- `--client-file <path>` signs in with a client you downloaded.
|
|
44
|
+
- `--client-id <id> --client-secret <secret>` signs in with a client you already have.
|
|
45
|
+
- `--setup` starts the walkthrough over, with a new project and client.
|
|
46
|
+
|
|
47
|
+
## What it keeps
|
|
48
|
+
|
|
49
|
+
- **The client and the setup's progress**: `google/setup.json` in Chronicle's config directory (`~/.config/chronicle`), readable only by you.
|
|
50
|
+
- **Tokens**, one entry per account: Chronicle's `credentials.json`, beside it.
|
|
51
|
+
- **gcloud's sign-in, only while setup runs**: `google/gcloud`, a gcloud configuration of Chronicle's own (`CLOUDSDK_CONFIG`), so your usual gcloud accounts, project, and application default credentials stay as they were. Its sign-in can do far more than Chronicle needs, so when setup is done Chronicle signs out (`gcloud auth revoke`) and removes it. `--add` signs in again just to turn the new API on, then signs out. A setup that stops partway keeps it, so the next run picks up without another sign-in.
|
|
52
|
+
|
|
53
|
+
### Contacts link people; they aren't extracted
|
|
54
|
+
|
|
55
|
+
Contacts isn't a source of its own, and it's off unless you ask for it. With `--link-contacts`, Gmail and Calendar read your contacts once a run and look up each address they see: a person on one of your contacts is `sameAs` the contact (`contactIdentities`): an Agent in the `google-contacts` namespace, keyed by the contact's ID within your Google account (`inAccount`), named as you saved it (not the name on the person's own Google profile), and `sameAs` its other addresses (in the `email` namespace) and phone numbers (in the `phone` namespace, E.164), the identities iMessage and call history key people by. Add the access with `chronicle auth login google --add contacts`; without it, a run with `--link-contacts` goes on unlinked and says how to add it.
|
|
56
|
+
|
|
57
|
+
## For sources
|
|
58
|
+
|
|
59
|
+
A Google source imports this package, which registers the `google` provider. It reads through `GoogleApi`, with `googleAccountOptions` in its schema:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const api = new GoogleApi({ service: 'calendar', baseURL, account, accessToken });
|
|
63
|
+
await api.initialize();
|
|
64
|
+
for await (const item of api.pages('/users/me/calendarList')) …
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`GoogleApi` turns Google's refusals into errors that say what to run. A missing scope says to run `chronicle auth login google --add <service>`, an API that's off in the project says the same (which turns it on), and a rejected token says to sign in again. Services, their scopes, and their APIs are listed in `GOOGLE_SERVICES`. `ContactDirectory.load()` reads your contacts for a run when `linkContacts` (from `contactOptions`) is set, `linksFor(addresses)` gives the links for the addresses on a record, and `contactIdentities(address, links)` turns them into `sameAs` identities.
|
|
68
|
+
|
|
69
|
+
## Tests
|
|
70
|
+
|
|
71
|
+
The tests run setup against a fake gcloud and a temporary config directory, and Google's API against a server on 127.0.0.1. They never run gcloud or read the host's accounts or files.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type AxiosRequestConfig } from 'axios';
|
|
2
|
+
import { ApiProxy } from '@chronicle.app/etl';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { type GoogleService } from './services.js';
|
|
5
|
+
/** The options every Google extractor takes: which account, or a token outright. */
|
|
6
|
+
export declare const googleAccountOptions: {
|
|
7
|
+
account: z.ZodOptional<z.ZodString>;
|
|
8
|
+
accessToken: z.ZodOptional<z.ZodString>;
|
|
9
|
+
};
|
|
10
|
+
export interface GoogleApiOptions {
|
|
11
|
+
/** Which service this client reads, for the scope and API in its errors. */
|
|
12
|
+
service: GoogleService;
|
|
13
|
+
baseURL: string;
|
|
14
|
+
account?: string;
|
|
15
|
+
accessToken?: string;
|
|
16
|
+
pageDelayMs?: number;
|
|
17
|
+
/** Waits before each retry of a rate-limited request, in ms. */
|
|
18
|
+
retryDelaysMs?: number[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A Google REST API, signed in as one account. Google's refusals become
|
|
22
|
+
* typed errors that say what to run: a missing scope adds access, an API
|
|
23
|
+
* that's off gets turned on, an expired sign-in signs in again.
|
|
24
|
+
*/
|
|
25
|
+
export declare class GoogleApi extends ApiProxy {
|
|
26
|
+
private readonly service;
|
|
27
|
+
private readonly account?;
|
|
28
|
+
private readonly retryDelaysMs;
|
|
29
|
+
constructor(options: GoogleApiOptions);
|
|
30
|
+
/** The token came from a stored sign-in, so it can be refreshed. */
|
|
31
|
+
private readonly stored;
|
|
32
|
+
initialize(): Promise<void>;
|
|
33
|
+
get<T>(path: string, params?: Record<string, unknown>): Promise<T>;
|
|
34
|
+
/** Every item of a list endpoint, page by page, following `nextPageToken`. */
|
|
35
|
+
pages<T>(path: string, params?: Record<string, unknown>, itemsKey?: string): AsyncGenerator<T>;
|
|
36
|
+
/**
|
|
37
|
+
* A request, retried with backoff while Google says it's rate limited, and
|
|
38
|
+
* once with a refreshed token when Google refuses one that expired during
|
|
39
|
+
* a long run.
|
|
40
|
+
*/
|
|
41
|
+
protected request<T>(config: AxiosRequestConfig): Promise<T>;
|
|
42
|
+
protected mapError(error: unknown): unknown;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=GoogleApi.d.ts.map
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { isAxiosError } from 'axios';
|
|
2
|
+
import { ApiAuthError, ApiProxy } from '@chronicle.app/etl';
|
|
3
|
+
import { TokenHelper } from '@chronicle.app/auth';
|
|
4
|
+
import { AuthRequired, EXIT_CODES, ExtractorError, RateLimited } from '@chronicle.app/logging';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { GOOGLE_SERVICES } from './services.js';
|
|
7
|
+
/** The options every Google extractor takes: which account, or a token outright. */
|
|
8
|
+
export const googleAccountOptions = {
|
|
9
|
+
account: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe('The Google account to use, by email. The default is the last one signed in.'),
|
|
13
|
+
accessToken: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('A Google access token to use instead of a stored sign-in'),
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Google's per-minute quotas refill within a minute: back off for about that
|
|
20
|
+
* long, doubling, before giving up.
|
|
21
|
+
*/
|
|
22
|
+
const RETRY_DELAYS_MS = [2000, 4000, 8000, 16_000, 32_000];
|
|
23
|
+
/** Google's reasons for a rate limit, as a 403 or a 429. */
|
|
24
|
+
const RATE_LIMITED = new Set([
|
|
25
|
+
'rateLimitExceeded',
|
|
26
|
+
'userRateLimitExceeded',
|
|
27
|
+
'RATE_LIMIT_EXCEEDED',
|
|
28
|
+
'quotaExceeded',
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* A Google REST API, signed in as one account. Google's refusals become
|
|
32
|
+
* typed errors that say what to run: a missing scope adds access, an API
|
|
33
|
+
* that's off gets turned on, an expired sign-in signs in again.
|
|
34
|
+
*/
|
|
35
|
+
export class GoogleApi extends ApiProxy {
|
|
36
|
+
service;
|
|
37
|
+
account;
|
|
38
|
+
retryDelaysMs;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
super({ baseURL: options.baseURL, pageDelayMs: options.pageDelayMs ?? 0 });
|
|
41
|
+
this.service = options.service;
|
|
42
|
+
this.account = options.account;
|
|
43
|
+
this.retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
44
|
+
if (options.accessToken)
|
|
45
|
+
this.setAccessToken(options.accessToken);
|
|
46
|
+
this.stored = !options.accessToken;
|
|
47
|
+
}
|
|
48
|
+
/** The token came from a stored sign-in, so it can be refreshed. */
|
|
49
|
+
stored;
|
|
50
|
+
async initialize() {
|
|
51
|
+
if (this.accessToken)
|
|
52
|
+
return;
|
|
53
|
+
this.setAccessToken(await TokenHelper.getValidToken('google', { account: this.account }));
|
|
54
|
+
}
|
|
55
|
+
get(path, params) {
|
|
56
|
+
// Google wants a list as the same name repeated (`labelIds=A&labelIds=B`).
|
|
57
|
+
return this.request({
|
|
58
|
+
method: 'GET',
|
|
59
|
+
url: path,
|
|
60
|
+
params,
|
|
61
|
+
paramsSerializer: { indexes: null },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Every item of a list endpoint, page by page, following `nextPageToken`. */
|
|
65
|
+
async *pages(path, params = {}, itemsKey = 'items') {
|
|
66
|
+
let pageToken;
|
|
67
|
+
do {
|
|
68
|
+
const page = await this.get(path, { ...params, pageToken });
|
|
69
|
+
yield* (page[itemsKey] ?? []);
|
|
70
|
+
pageToken = page.nextPageToken;
|
|
71
|
+
if (pageToken)
|
|
72
|
+
await this.delay();
|
|
73
|
+
} while (pageToken);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A request, retried with backoff while Google says it's rate limited, and
|
|
77
|
+
* once with a refreshed token when Google refuses one that expired during
|
|
78
|
+
* a long run.
|
|
79
|
+
*/
|
|
80
|
+
async request(config) {
|
|
81
|
+
let refreshed = false;
|
|
82
|
+
for (let attempt = 0;; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
return await super.request(config);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof ApiAuthError && this.stored && !refreshed) {
|
|
88
|
+
refreshed = true;
|
|
89
|
+
this.setAccessToken(await TokenHelper.getValidToken('google', { account: this.account, refresh: true }));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!(error instanceof RateLimited) || attempt >= this.retryDelaysMs.length)
|
|
93
|
+
throw error;
|
|
94
|
+
await this.delay(this.retryDelaysMs[attempt]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
mapError(error) {
|
|
99
|
+
const { label } = GOOGLE_SERVICES[this.service];
|
|
100
|
+
const reasons = isAxiosError(error) ? googleReasons(error.response?.data) : new Set();
|
|
101
|
+
if ([...reasons].some(reason => RATE_LIMITED.has(reason))) {
|
|
102
|
+
return new RateLimited(`Google’s ${label} quota is used up for now`, {
|
|
103
|
+
hint: 'Try again in a minute.',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (isAxiosError(error) && error.response?.status === 403) {
|
|
107
|
+
if (reasons.has('ACCESS_TOKEN_SCOPE_INSUFFICIENT') ||
|
|
108
|
+
reasons.has('insufficientPermissions')) {
|
|
109
|
+
return new AuthRequired(`No access to ${label}`, {
|
|
110
|
+
hint: `Run \`chronicle auth login google --add ${this.service}\` to add it.`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (reasons.has('SERVICE_DISABLED') || reasons.has('accessNotConfigured')) {
|
|
114
|
+
// Google says where to turn it on; that page is the quickest way.
|
|
115
|
+
const page = activationUrl(error.response.data);
|
|
116
|
+
return new ExtractorError(`The ${label} API is off in your Google Cloud project`, {
|
|
117
|
+
code: 'api-disabled',
|
|
118
|
+
exitCode: EXIT_CODES.auth,
|
|
119
|
+
hint: page
|
|
120
|
+
? `Turn it on here, then run this again: ${page}\nOr run \`chronicle auth login google --add ${this.service}\` to turn it on.`
|
|
121
|
+
: `Run \`chronicle auth login google --add ${this.service}\` to turn it on.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (isAxiosError(error) && error.response?.status === 404) {
|
|
126
|
+
return new ExtractorError(`${label}: not found`, {
|
|
127
|
+
code: 'not-found',
|
|
128
|
+
exitCode: EXIT_CODES.input,
|
|
129
|
+
cause: error,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const mapped = super.mapError(error);
|
|
133
|
+
if (mapped instanceof ApiAuthError) {
|
|
134
|
+
return new ApiAuthError('Google rejected the stored sign-in', {
|
|
135
|
+
hint: 'Run `chronicle auth login google` to sign in again.',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// Anything else: Google's own words, not just the status code.
|
|
139
|
+
const said = isAxiosError(error) && googleMessage(error.response?.data);
|
|
140
|
+
if (said && mapped === error) {
|
|
141
|
+
return new ExtractorError(`${label}: ${said}`, {
|
|
142
|
+
code: 'api-error',
|
|
143
|
+
exitCode: EXIT_CODES.internal,
|
|
144
|
+
cause: error,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return mapped;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** The reasons in a Google error body, from either of its two shapes. */
|
|
151
|
+
function googleReasons(body) {
|
|
152
|
+
const error = body?.error ?? {};
|
|
153
|
+
return new Set([
|
|
154
|
+
...(error.errors ?? []).map((e) => e?.reason),
|
|
155
|
+
...(error.details ?? []).map((d) => d?.reason),
|
|
156
|
+
].filter((reason) => typeof reason === 'string'));
|
|
157
|
+
}
|
|
158
|
+
/** The message in a Google error body, if it has one. */
|
|
159
|
+
function googleMessage(body) {
|
|
160
|
+
const message = body?.error?.message;
|
|
161
|
+
return typeof message === 'string' && message ? message : undefined;
|
|
162
|
+
}
|
|
163
|
+
/** The page that turns an API on, from a Google "service disabled" error. */
|
|
164
|
+
function activationUrl(body) {
|
|
165
|
+
for (const detail of body?.error?.details ?? []) {
|
|
166
|
+
const url = detail?.metadata?.activationUrl;
|
|
167
|
+
if (typeof url === 'string')
|
|
168
|
+
return url;
|
|
169
|
+
}
|
|
170
|
+
return body?.error?.message?.match(/https:\/\/console\.developers\.google\.com\/\S+?(?=\s|$)/)?.[0];
|
|
171
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { OAuthProvider, type ProviderSetupContext } from '@chronicle.app/auth';
|
|
2
|
+
/**
|
|
3
|
+
* One Google sign-in for every Google source, with an OAuth client the person
|
|
4
|
+
* registers in their own Google Cloud project: Chronicle ships no client.
|
|
5
|
+
* Each account signed in is kept apart by its email.
|
|
6
|
+
*/
|
|
7
|
+
export declare class GoogleOAuthProvider extends OAuthProvider {
|
|
8
|
+
static providerId: string;
|
|
9
|
+
static authorizationUrl: string;
|
|
10
|
+
static tokenUrl: string;
|
|
11
|
+
static scopes: string[];
|
|
12
|
+
static scopeSets: Record<string, string[]>;
|
|
13
|
+
static defaultScopeSets: string[];
|
|
14
|
+
static requiresClientSecret: boolean;
|
|
15
|
+
static pkce: boolean;
|
|
16
|
+
static extraAuthParams: {
|
|
17
|
+
access_type: string;
|
|
18
|
+
prompt: string;
|
|
19
|
+
include_granted_scopes: string;
|
|
20
|
+
};
|
|
21
|
+
static signedInHint: string;
|
|
22
|
+
static setup(context: ProviderSetupContext): Promise<import("@chronicle.app/auth").ClientCredentials>;
|
|
23
|
+
/** The email in the id_token, which came straight from Google's token endpoint. */
|
|
24
|
+
protected accountFrom(response: any): string | undefined;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=GoogleOAuthProvider.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { OAuthProvider } from '@chronicle.app/auth';
|
|
2
|
+
import { GOOGLE_SERVICES } from './services.js';
|
|
3
|
+
import { setupGoogleClient } from './setup.js';
|
|
4
|
+
/**
|
|
5
|
+
* One Google sign-in for every Google source, with an OAuth client the person
|
|
6
|
+
* registers in their own Google Cloud project: Chronicle ships no client.
|
|
7
|
+
* Each account signed in is kept apart by its email.
|
|
8
|
+
*/
|
|
9
|
+
export class GoogleOAuthProvider extends OAuthProvider {
|
|
10
|
+
static providerId = 'google';
|
|
11
|
+
static authorizationUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
12
|
+
static tokenUrl = 'https://oauth2.googleapis.com/token';
|
|
13
|
+
// `openid email` brings back an id_token naming the account.
|
|
14
|
+
static scopes = ['openid', 'email'];
|
|
15
|
+
static scopeSets = Object.fromEntries(Object.entries(GOOGLE_SERVICES).map(([name, service]) => [name, [...service.scopes]]));
|
|
16
|
+
static defaultScopeSets = ['gmail', 'calendar'];
|
|
17
|
+
static requiresClientSecret = true;
|
|
18
|
+
static pkce = true;
|
|
19
|
+
// Without offline access + forced consent Google issues no refresh token,
|
|
20
|
+
// leaving the stored credential unable to outlive the first hour.
|
|
21
|
+
// include_granted_scopes keeps what was granted before when access is added.
|
|
22
|
+
static extraAuthParams = {
|
|
23
|
+
access_type: 'offline',
|
|
24
|
+
prompt: 'consent',
|
|
25
|
+
include_granted_scopes: 'true',
|
|
26
|
+
};
|
|
27
|
+
static signedInHint = [
|
|
28
|
+
'Run `chronicle extract google-calendar` to extract your calendar.',
|
|
29
|
+
'Run `chronicle extract gmail` to extract your mail.',
|
|
30
|
+
].join('\n');
|
|
31
|
+
static setup(context) {
|
|
32
|
+
return setupGoogleClient(context);
|
|
33
|
+
}
|
|
34
|
+
/** The email in the id_token, which came straight from Google's token endpoint. */
|
|
35
|
+
accountFrom(response) {
|
|
36
|
+
const payload = response?.id_token?.split('.')[1];
|
|
37
|
+
if (!payload)
|
|
38
|
+
return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
41
|
+
return typeof claims.email === 'string' ? claims.email : undefined;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Agent } from '@chronicle.app/schema';
|
|
2
|
+
/**
|
|
3
|
+
* A Google account by its address, in the `google-account` namespace, so
|
|
4
|
+
* Gmail, Google Calendar, and Chrome name the same account the same way. An
|
|
5
|
+
* address is the one identifier every way in has: an API, a Takeout, or a
|
|
6
|
+
* browser profile. The source's node for you is `sameAs` it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function googleAccount(address: string): Agent;
|
|
9
|
+
//# sourceMappingURL=account.d.ts.map
|
package/dist/account.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Google account by its address, in the `google-account` namespace, so
|
|
3
|
+
* Gmail, Google Calendar, and Chrome name the same account the same way. An
|
|
4
|
+
* address is the one identifier every way in has: an API, a Takeout, or a
|
|
5
|
+
* browser profile. The source's node for you is `sameAs` it.
|
|
6
|
+
*/
|
|
7
|
+
export function googleAccount(address) {
|
|
8
|
+
return {
|
|
9
|
+
'@type': 'Agent',
|
|
10
|
+
'@key': ['@type', 'source', 'handle'],
|
|
11
|
+
source: 'google-account',
|
|
12
|
+
handle: address.toLowerCase(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Agent } from '@chronicle.app/schema';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* What your contacts say about an email address: the contact's other
|
|
5
|
+
* addresses, lowercased, and its phone numbers, in E.164. Plain data, so it
|
|
6
|
+
* rides a raw record.
|
|
7
|
+
*/
|
|
8
|
+
export interface ContactLinks {
|
|
9
|
+
/** The contact's ID in the account's contacts, from its `people/<id>` name. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** The name you saved for the contact, not the person's own profile name. */
|
|
12
|
+
name?: string;
|
|
13
|
+
emails: string[];
|
|
14
|
+
phones: string[];
|
|
15
|
+
}
|
|
16
|
+
/** The option a Google source takes to link people to your contacts. Off unless asked for. */
|
|
17
|
+
export declare const contactOptions: {
|
|
18
|
+
linkContacts: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Your Google Contacts as a lookup, not a source: Gmail and Calendar ask it
|
|
22
|
+
* about each address they see, and link the person to the contact's other
|
|
23
|
+
* addresses and numbers. Nothing from it is extracted on its own.
|
|
24
|
+
*/
|
|
25
|
+
export declare class ContactDirectory {
|
|
26
|
+
private readonly byAddress;
|
|
27
|
+
/** Swapped for a fake server in tests. */
|
|
28
|
+
static apiBaseURL: string;
|
|
29
|
+
private constructor();
|
|
30
|
+
/** A directory with no contacts in it, for a run without Contacts access. */
|
|
31
|
+
static readonly empty: ContactDirectory;
|
|
32
|
+
/**
|
|
33
|
+
* Your contacts, read once for a run with `linkContacts`. Without Contacts
|
|
34
|
+
* access, or with the People API off, it's empty and says why: linking is
|
|
35
|
+
* a bonus, so the run goes on.
|
|
36
|
+
*/
|
|
37
|
+
static load(options: {
|
|
38
|
+
account?: string;
|
|
39
|
+
accessToken?: string;
|
|
40
|
+
linkContacts?: boolean;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
directory: ContactDirectory;
|
|
43
|
+
missing?: unknown;
|
|
44
|
+
}>;
|
|
45
|
+
/** The links for each of these addresses that's on a contact. */
|
|
46
|
+
linksFor(addresses: string[]): {
|
|
47
|
+
[address: string]: ContactLinks;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Your contact for an address, as the identity it links the address to: the
|
|
52
|
+
* entry in the Google account's contacts, keyed by its ID within the account,
|
|
53
|
+
* named as you saved it, and `sameAs` its other addresses in the `email`
|
|
54
|
+
* namespace and its numbers in the `phone` namespace, keyed as mail,
|
|
55
|
+
* messages, and calls key people. None without the account whose contacts
|
|
56
|
+
* they are.
|
|
57
|
+
*/
|
|
58
|
+
export declare function contactIdentities(address: string, links: ContactLinks | undefined, account: string | null | undefined): Agent[];
|
|
59
|
+
//# sourceMappingURL=contacts.d.ts.map
|
package/dist/contacts.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { normalizePhoneNumber } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { googleAccount } from './account.js';
|
|
4
|
+
import { GoogleApi } from './GoogleApi.js';
|
|
5
|
+
/** The option a Google source takes to link people to your contacts. Off unless asked for. */
|
|
6
|
+
export const contactOptions = {
|
|
7
|
+
linkContacts: z
|
|
8
|
+
.boolean()
|
|
9
|
+
.optional()
|
|
10
|
+
.describe('Link people to the other addresses and phone numbers your Google Contacts have for them'),
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Your Google Contacts as a lookup, not a source: Gmail and Calendar ask it
|
|
14
|
+
* about each address they see, and link the person to the contact's other
|
|
15
|
+
* addresses and numbers. Nothing from it is extracted on its own.
|
|
16
|
+
*/
|
|
17
|
+
export class ContactDirectory {
|
|
18
|
+
byAddress;
|
|
19
|
+
/** Swapped for a fake server in tests. */
|
|
20
|
+
static apiBaseURL = 'https://people.googleapis.com/v1';
|
|
21
|
+
constructor(byAddress) {
|
|
22
|
+
this.byAddress = byAddress;
|
|
23
|
+
}
|
|
24
|
+
/** A directory with no contacts in it, for a run without Contacts access. */
|
|
25
|
+
static empty = new ContactDirectory(new Map());
|
|
26
|
+
/**
|
|
27
|
+
* Your contacts, read once for a run with `linkContacts`. Without Contacts
|
|
28
|
+
* access, or with the People API off, it's empty and says why: linking is
|
|
29
|
+
* a bonus, so the run goes on.
|
|
30
|
+
*/
|
|
31
|
+
static async load(options) {
|
|
32
|
+
// Not asked for: your address book isn't read at all.
|
|
33
|
+
if (!options.linkContacts)
|
|
34
|
+
return { directory: ContactDirectory.empty };
|
|
35
|
+
const api = new GoogleApi({
|
|
36
|
+
service: 'contacts',
|
|
37
|
+
baseURL: ContactDirectory.apiBaseURL,
|
|
38
|
+
account: options.account,
|
|
39
|
+
accessToken: options.accessToken,
|
|
40
|
+
});
|
|
41
|
+
const byAddress = new Map();
|
|
42
|
+
try {
|
|
43
|
+
await api.initialize();
|
|
44
|
+
const people = api.pages('/people/me/connections', { personFields: 'names,emailAddresses,phoneNumbers', pageSize: 1000 }, 'connections');
|
|
45
|
+
for await (const person of people) {
|
|
46
|
+
const emails = unique((person.emailAddresses ?? []).map(email => email.value?.trim().toLowerCase()));
|
|
47
|
+
const phones = unique((person.phoneNumbers ?? []).map(phone => phone.canonicalForm ?? (phone.value ? normalizePhoneNumber(phone.value) : null)));
|
|
48
|
+
const id = person.resourceName?.replace(/^people\//, '');
|
|
49
|
+
// A contact is found by its addresses, so one without any can't be.
|
|
50
|
+
if (!id || emails.length === 0)
|
|
51
|
+
continue;
|
|
52
|
+
// The name you saved (a CONTACT source), not the one the person gave
|
|
53
|
+
// their own Google profile (a PROFILE source).
|
|
54
|
+
const name = person.names
|
|
55
|
+
?.find(entry => entry.metadata?.source?.type === 'CONTACT')
|
|
56
|
+
?.displayName?.trim();
|
|
57
|
+
for (const email of emails) {
|
|
58
|
+
byAddress.set(email, { id, ...(name && { name }), emails, phones });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const code = error?.code;
|
|
64
|
+
if (code === 'auth-required' || code === 'api-disabled') {
|
|
65
|
+
return { directory: ContactDirectory.empty, missing: error };
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return { directory: new ContactDirectory(byAddress) };
|
|
70
|
+
}
|
|
71
|
+
/** The links for each of these addresses that's on a contact. */
|
|
72
|
+
linksFor(addresses) {
|
|
73
|
+
const found = {};
|
|
74
|
+
for (const address of addresses) {
|
|
75
|
+
const key = address.toLowerCase();
|
|
76
|
+
const links = this.byAddress.get(key);
|
|
77
|
+
if (links)
|
|
78
|
+
found[key] = links;
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Your contact for an address, as the identity it links the address to: the
|
|
85
|
+
* entry in the Google account's contacts, keyed by its ID within the account,
|
|
86
|
+
* named as you saved it, and `sameAs` its other addresses in the `email`
|
|
87
|
+
* namespace and its numbers in the `phone` namespace, keyed as mail,
|
|
88
|
+
* messages, and calls key people. None without the account whose contacts
|
|
89
|
+
* they are.
|
|
90
|
+
*/
|
|
91
|
+
export function contactIdentities(address, links, account) {
|
|
92
|
+
if (!links || !account)
|
|
93
|
+
return [];
|
|
94
|
+
const self = address.toLowerCase();
|
|
95
|
+
const identity = (source, handle) => ({
|
|
96
|
+
'@type': 'Agent',
|
|
97
|
+
'@key': ['@type', 'source', 'handle'],
|
|
98
|
+
source,
|
|
99
|
+
handle,
|
|
100
|
+
});
|
|
101
|
+
const sameAs = [
|
|
102
|
+
...links.emails.filter(email => email !== self).map(email => identity('email', email)),
|
|
103
|
+
...links.phones.map(phone => identity('phone', phone)),
|
|
104
|
+
];
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
'@type': 'Agent',
|
|
108
|
+
'@key': ['@type', 'source', 'inAccount[*].handle', 'sourceId'],
|
|
109
|
+
source: 'google-contacts',
|
|
110
|
+
sourceId: links.id,
|
|
111
|
+
inAccount: [googleAccount(account)],
|
|
112
|
+
...(links.name && { name: links.name }),
|
|
113
|
+
...(sameAs.length > 0 && { sameAs }),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
const unique = (values) => [
|
|
118
|
+
...new Set(values.flatMap(value => (value ? [value] : []))),
|
|
119
|
+
];
|
package/dist/gcloud.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** What setup needs from Google Cloud's command line. */
|
|
2
|
+
export interface Gcloud {
|
|
3
|
+
/** Whether `gcloud` is installed. */
|
|
4
|
+
available(): Promise<boolean>;
|
|
5
|
+
/** The signed-in account, if any. */
|
|
6
|
+
account(): Promise<string | undefined>;
|
|
7
|
+
/** Sign in; gcloud opens the browser and talks to the terminal itself. */
|
|
8
|
+
login(): Promise<void>;
|
|
9
|
+
createProject(projectId: string, name: string): Promise<void>;
|
|
10
|
+
/** IDs of the projects the signed-in account can see. */
|
|
11
|
+
listProjects(): Promise<string[]>;
|
|
12
|
+
enableApis(projectId: string, apis: string[]): Promise<void>;
|
|
13
|
+
/** Sign out and remove gcloud's state: its sign-in can do far more than setup needs. */
|
|
14
|
+
signOut(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/** A gcloud command that failed, with what it said. */
|
|
17
|
+
export declare class GcloudError extends Error {
|
|
18
|
+
readonly args: string[];
|
|
19
|
+
readonly stderr: string;
|
|
20
|
+
/** gcloud's error, unwrapped into one line, without its `ERROR: (gcloud.…)` prefix. */
|
|
21
|
+
readonly said: string;
|
|
22
|
+
constructor(args: string[], stderr: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The error in gcloud's output: from its `ERROR:` line to the next blank
|
|
26
|
+
* line, which gcloud wraps at 80 columns, as one line. Anything else it
|
|
27
|
+
* printed (update notices) is left out.
|
|
28
|
+
*/
|
|
29
|
+
export declare function gcloudSaid(stderr: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* gcloud with its own config directory (`CLOUDSDK_CONFIG`), so the account
|
|
32
|
+
* and project Chronicle sets up never touch a person's everyday gcloud: its
|
|
33
|
+
* accounts, active project, and application default credentials stay as
|
|
34
|
+
* they were. Commands run with argument arrays, never through a shell.
|
|
35
|
+
*/
|
|
36
|
+
export declare class CommandLineGcloud implements Gcloud {
|
|
37
|
+
private readonly configDir;
|
|
38
|
+
private readonly bin;
|
|
39
|
+
constructor(configDir: string, bin?: string);
|
|
40
|
+
available(): Promise<boolean>;
|
|
41
|
+
account(): Promise<string | undefined>;
|
|
42
|
+
login(): Promise<void>;
|
|
43
|
+
createProject(projectId: string, name: string): Promise<void>;
|
|
44
|
+
listProjects(): Promise<string[]>;
|
|
45
|
+
enableApis(projectId: string, apis: string[]): Promise<void>;
|
|
46
|
+
signOut(): Promise<void>;
|
|
47
|
+
private run;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=gcloud.d.ts.map
|