nothumanallowed 6.8.4 → 7.1.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/package.json +1 -1
- package/src/commands/ui.mjs +194 -1
- package/src/constants.mjs +1 -1
- package/src/services/google-contacts.mjs +250 -0
- package/src/services/google-oauth.mjs +2 -0
- package/src/services/google-tasks.mjs +147 -0
- package/src/services/microsoft-drive.mjs +185 -0
- package/src/services/microsoft-oauth.mjs +1 -0
- package/src/services/microsoft-todo.mjs +217 -0
- package/src/services/notes.mjs +134 -0
- package/src/services/web-ui.mjs +156 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents for security, code, DevOps, data & daily ops. Per-agent memory, Telegram + Discord auto-responder, proactive intelligence daemon, voice chat, plugin system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/ui.mjs
CHANGED
|
@@ -111,7 +111,34 @@ TOOLS:
|
|
|
111
111
|
Delete an email. Query can be a messageId OR a search term like "pranzo from:me in:sent".
|
|
112
112
|
The system finds the matching email and moves it to Trash. ALWAYS confirm before deleting.
|
|
113
113
|
|
|
114
|
-
21.
|
|
114
|
+
21. contact_add(name: string, email?: string, phone?: string, company?: string, address?: string)
|
|
115
|
+
Add a new contact to Google Contacts.
|
|
116
|
+
|
|
117
|
+
22. contact_update(query: string, email?: string, phone?: string, company?: string, address?: string)
|
|
118
|
+
Update an existing contact. Query is the contact name to search for.
|
|
119
|
+
|
|
120
|
+
23. contact_delete(query: string)
|
|
121
|
+
Delete a contact by name. ALWAYS confirm before deleting.
|
|
122
|
+
|
|
123
|
+
24. contact_search(query: string)
|
|
124
|
+
Search contacts by name, email or phone. Returns matching contacts.
|
|
125
|
+
|
|
126
|
+
25. gtask_list()
|
|
127
|
+
List Google Tasks (not completed).
|
|
128
|
+
|
|
129
|
+
26. gtask_add(title: string, notes?: string, due?: string)
|
|
130
|
+
Add a Google Task. due is YYYY-MM-DD.
|
|
131
|
+
|
|
132
|
+
27. gtask_complete(title: string)
|
|
133
|
+
Complete a Google Task by title.
|
|
134
|
+
|
|
135
|
+
28. note_add(title: string, content?: string)
|
|
136
|
+
Create a local note.
|
|
137
|
+
|
|
138
|
+
29. note_list()
|
|
139
|
+
List all local notes.
|
|
140
|
+
|
|
141
|
+
30. maps_directions(from: string, to: string)
|
|
115
142
|
Generate a Google Maps directions link between two locations.
|
|
116
143
|
|
|
117
144
|
RULES:
|
|
@@ -417,6 +444,72 @@ async function executeTool(action, params, config) {
|
|
|
417
444
|
await gm.trashMessage(cfg, messageId);
|
|
418
445
|
return `Email moved to Trash.`;
|
|
419
446
|
}
|
|
447
|
+
// ── Contacts CRUD ──────────────────────────────────────────────────
|
|
448
|
+
case 'contact_add': {
|
|
449
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
450
|
+
const contact = await gc.createContact(config, {
|
|
451
|
+
name: params.name, email: params.email, phone: params.phone,
|
|
452
|
+
company: params.company, address: params.address,
|
|
453
|
+
});
|
|
454
|
+
return `Contact created: ${contact.name}${contact.phone ? ' — ' + contact.phone : ''}${contact.email ? ' — ' + contact.email : ''}`;
|
|
455
|
+
}
|
|
456
|
+
case 'contact_update': {
|
|
457
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
458
|
+
const matches = await gc.searchContacts(config, params.query, 1);
|
|
459
|
+
if (matches.length === 0) return `No contact found matching "${params.query}".`;
|
|
460
|
+
const updated = await gc.updateContact(config, matches[0].resourceName, {
|
|
461
|
+
email: params.email, phone: params.phone, company: params.company, address: params.address,
|
|
462
|
+
});
|
|
463
|
+
return `Contact updated: ${updated.name}`;
|
|
464
|
+
}
|
|
465
|
+
case 'contact_delete': {
|
|
466
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
467
|
+
const matches = await gc.searchContacts(config, params.query, 1);
|
|
468
|
+
if (matches.length === 0) return `No contact found matching "${params.query}".`;
|
|
469
|
+
await gc.deleteContact(config, matches[0].resourceName);
|
|
470
|
+
return `Contact "${matches[0].name}" deleted.`;
|
|
471
|
+
}
|
|
472
|
+
case 'contact_search': {
|
|
473
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
474
|
+
const contacts = await gc.searchContacts(config, params.query, 10);
|
|
475
|
+
if (contacts.length === 0) return `No contacts matching "${params.query}".`;
|
|
476
|
+
return contacts.map((c, i) => `${i + 1}. ${c.name}${c.email ? ' — ' + c.email : ''}${c.phone ? ' — ' + c.phone : ''}${c.company ? ' (' + c.company + ')' : ''}`).join('\n');
|
|
477
|
+
}
|
|
478
|
+
// ── Google Tasks CRUD ────────────────────────────────────────────────
|
|
479
|
+
case 'gtask_list': {
|
|
480
|
+
const gt = await import('../services/google-tasks.mjs');
|
|
481
|
+
const tasks = await gt.listTasks(config);
|
|
482
|
+
if (tasks.length === 0) return 'No active Google Tasks.';
|
|
483
|
+
return tasks.map((t, i) => `${i + 1}. ${t.title}${t.due ? ' (due: ' + t.due.split('T')[0] + ')' : ''}${t.notes ? ' — ' + t.notes.slice(0, 80) : ''}`).join('\n');
|
|
484
|
+
}
|
|
485
|
+
case 'gtask_add': {
|
|
486
|
+
const gt = await import('../services/google-tasks.mjs');
|
|
487
|
+
const lists = await gt.listTaskLists(config);
|
|
488
|
+
const listId = lists[0]?.id || '@default';
|
|
489
|
+
const task = await gt.createTask(config, listId, params.title, params.notes || '', params.due || '');
|
|
490
|
+
return `Google Task created: "${task.title}"${task.due ? ' (due: ' + task.due.split('T')[0] + ')' : ''}`;
|
|
491
|
+
}
|
|
492
|
+
case 'gtask_complete': {
|
|
493
|
+
const gt = await import('../services/google-tasks.mjs');
|
|
494
|
+
const tasks = await gt.listTasks(config);
|
|
495
|
+
const match = tasks.find(t => t.title.toLowerCase().includes((params.title || '').toLowerCase()));
|
|
496
|
+
if (!match) return `No task found matching "${params.title}".`;
|
|
497
|
+
await gt.completeTask(config, match.listId || '@default', match.id);
|
|
498
|
+
return `Task "${match.title}" completed.`;
|
|
499
|
+
}
|
|
500
|
+
// ── Notes CRUD ───────────────────────────────────────────────────────
|
|
501
|
+
case 'note_add': {
|
|
502
|
+
const ns = await import('../services/notes.mjs');
|
|
503
|
+
const note = ns.createNote(params.title || 'Untitled', params.content || '', params.tags || []);
|
|
504
|
+
return `Note created: "${note.title}"`;
|
|
505
|
+
}
|
|
506
|
+
case 'note_list': {
|
|
507
|
+
const ns = await import('../services/notes.mjs');
|
|
508
|
+
const notes = ns.listNotes();
|
|
509
|
+
if (notes.length === 0) return 'No notes.';
|
|
510
|
+
return notes.map((n, i) => `${i + 1}. ${n.title} (${new Date(n.updatedAt).toLocaleDateString()})`).join('\n');
|
|
511
|
+
}
|
|
512
|
+
// ── Maps ─────────────────────────────────────────────────────────────
|
|
420
513
|
case 'maps_directions': {
|
|
421
514
|
const from = encodeURIComponent(params.from || '');
|
|
422
515
|
const to = encodeURIComponent(params.to || '');
|
|
@@ -709,6 +802,106 @@ export async function cmdUI(args) {
|
|
|
709
802
|
return;
|
|
710
803
|
}
|
|
711
804
|
|
|
805
|
+
// POST /api/contacts — create contact
|
|
806
|
+
if (method === 'POST' && pathname === '/api/contacts') {
|
|
807
|
+
try {
|
|
808
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
809
|
+
const body = await parseBody(req);
|
|
810
|
+
const contact = await gc.createContact(config, body);
|
|
811
|
+
sendJSON(res, 201, { contact });
|
|
812
|
+
} catch (e) {
|
|
813
|
+
sendJSON(res, 200, { error: e.message });
|
|
814
|
+
}
|
|
815
|
+
logRequest(method, pathname, 201, Date.now() - start);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// DELETE /api/contacts/:resourceName
|
|
820
|
+
if (method === 'POST' && pathname.startsWith('/api/contacts/delete/')) {
|
|
821
|
+
try {
|
|
822
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
823
|
+
const rn = pathname.replace('/api/contacts/delete/', '');
|
|
824
|
+
await gc.deleteContact(config, 'people/' + rn);
|
|
825
|
+
sendJSON(res, 200, { ok: true });
|
|
826
|
+
} catch (e) {
|
|
827
|
+
sendJSON(res, 200, { error: e.message });
|
|
828
|
+
}
|
|
829
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// GET /api/contacts — list or search contacts
|
|
834
|
+
if (method === 'GET' && pathname === '/api/contacts') {
|
|
835
|
+
try {
|
|
836
|
+
const gc = await import('../services/google-contacts.mjs');
|
|
837
|
+
const q = url.searchParams.get('q');
|
|
838
|
+
let contacts;
|
|
839
|
+
if (q) {
|
|
840
|
+
contacts = await gc.searchContacts(config, q, 20);
|
|
841
|
+
} else {
|
|
842
|
+
contacts = await gc.listContacts(config, 50);
|
|
843
|
+
}
|
|
844
|
+
sendJSON(res, 200, { contacts });
|
|
845
|
+
} catch (e) {
|
|
846
|
+
sendJSON(res, 200, { contacts: [], error: e.message });
|
|
847
|
+
}
|
|
848
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// GET /api/notes — list notes | POST /api/notes — create note
|
|
853
|
+
if (pathname === '/api/notes' && !pathname.includes('/api/notes/')) {
|
|
854
|
+
if (method === 'GET') {
|
|
855
|
+
try {
|
|
856
|
+
const ns = await import('../services/notes.mjs');
|
|
857
|
+
const q = url.searchParams.get('q');
|
|
858
|
+
const notes = q ? ns.searchNotes(q) : ns.listNotes();
|
|
859
|
+
sendJSON(res, 200, { notes });
|
|
860
|
+
} catch (e) {
|
|
861
|
+
sendJSON(res, 200, { notes: [], error: e.message });
|
|
862
|
+
}
|
|
863
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (method === 'POST') {
|
|
867
|
+
try {
|
|
868
|
+
const ns = await import('../services/notes.mjs');
|
|
869
|
+
const body = await parseBody(req);
|
|
870
|
+
const note = ns.createNote(body.title || 'Untitled', body.content || '', body.tags || []);
|
|
871
|
+
sendJSON(res, 201, { note });
|
|
872
|
+
} catch (e) {
|
|
873
|
+
sendJSON(res, 200, { error: e.message });
|
|
874
|
+
}
|
|
875
|
+
logRequest(method, pathname, 201, Date.now() - start);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// GET /api/notes/:id — get note | POST /api/notes/:id — update | POST /api/notes/:id/delete — delete
|
|
881
|
+
const noteMatch = pathname.match(/^\/api\/notes\/([a-f0-9-]+)(\/delete)?$/);
|
|
882
|
+
if (noteMatch) {
|
|
883
|
+
const noteId = noteMatch[1];
|
|
884
|
+
const isDelete = noteMatch[2] === '/delete';
|
|
885
|
+
try {
|
|
886
|
+
const ns = await import('../services/notes.mjs');
|
|
887
|
+
if (isDelete && method === 'POST') {
|
|
888
|
+
ns.deleteNote(noteId);
|
|
889
|
+
sendJSON(res, 200, { ok: true });
|
|
890
|
+
} else if (method === 'POST') {
|
|
891
|
+
const body = await parseBody(req);
|
|
892
|
+
const note = ns.updateNote(noteId, body.title, body.content, body.tags);
|
|
893
|
+
sendJSON(res, 200, { ok: !!note, note });
|
|
894
|
+
} else {
|
|
895
|
+
const note = ns.getNote(noteId);
|
|
896
|
+
sendJSON(res, 200, { note });
|
|
897
|
+
}
|
|
898
|
+
} catch (e) {
|
|
899
|
+
sendJSON(res, 200, { error: e.message });
|
|
900
|
+
}
|
|
901
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
712
905
|
// GET /api/drive — list recent Drive files
|
|
713
906
|
if (method === 'GET' && pathname === '/api/drive') {
|
|
714
907
|
try {
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '
|
|
8
|
+
export const VERSION = '7.1.0';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google People API (Contacts) wrapper — zero dependencies.
|
|
3
|
+
* All calls via native fetch to People API v1.
|
|
4
|
+
* Auto-refreshes tokens on 401.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getAccessToken } from './token-store.mjs';
|
|
8
|
+
|
|
9
|
+
const PEOPLE_BASE = 'https://people.googleapis.com/v1';
|
|
10
|
+
|
|
11
|
+
const CONTACT_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,birthdays,addresses';
|
|
12
|
+
const CONTACT_FIELDS_FULL = 'names,emailAddresses,phoneNumbers,organizations,birthdays,addresses,biographies,urls';
|
|
13
|
+
|
|
14
|
+
/** Authenticated fetch with auto-retry on 401 */
|
|
15
|
+
async function peopleFetch(config, urlPath, options = {}) {
|
|
16
|
+
const token = await getAccessToken(config);
|
|
17
|
+
const url = urlPath.startsWith('http') ? urlPath : `${PEOPLE_BASE}${urlPath}`;
|
|
18
|
+
|
|
19
|
+
let res = await fetch(url, {
|
|
20
|
+
...options,
|
|
21
|
+
headers: {
|
|
22
|
+
'Authorization': `Bearer ${token}`,
|
|
23
|
+
...options.headers,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (res.status === 401) {
|
|
28
|
+
const newToken = await getAccessToken(config);
|
|
29
|
+
res = await fetch(url, {
|
|
30
|
+
...options,
|
|
31
|
+
headers: {
|
|
32
|
+
...options.headers,
|
|
33
|
+
'Authorization': `Bearer ${newToken}`,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const err = await res.text();
|
|
40
|
+
throw new Error(`People API ${res.status}: ${err}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
if (!text) return {};
|
|
45
|
+
try { return JSON.parse(text); } catch { return {}; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Search contacts by name or email.
|
|
50
|
+
* @param {object} config
|
|
51
|
+
* @param {string} query — search term (name, email, phone)
|
|
52
|
+
* @param {number} maxResults
|
|
53
|
+
* @returns {Promise<Array>}
|
|
54
|
+
*/
|
|
55
|
+
export async function searchContacts(config, query, maxResults = 10) {
|
|
56
|
+
const params = new URLSearchParams({
|
|
57
|
+
query,
|
|
58
|
+
readMask: CONTACT_FIELDS,
|
|
59
|
+
sources: 'READ_SOURCE_TYPE_CONTACT',
|
|
60
|
+
pageSize: String(maxResults),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const data = await peopleFetch(config, `/people:searchContacts?${params}`);
|
|
64
|
+
return (data.results || []).map(r => parseContact(r.person));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* List all contacts sorted by first name.
|
|
69
|
+
* @param {object} config
|
|
70
|
+
* @param {number} maxResults
|
|
71
|
+
* @returns {Promise<Array>}
|
|
72
|
+
*/
|
|
73
|
+
export async function listContacts(config, maxResults = 50) {
|
|
74
|
+
const params = new URLSearchParams({
|
|
75
|
+
personFields: CONTACT_FIELDS,
|
|
76
|
+
sortOrder: 'FIRST_NAME_ASCENDING',
|
|
77
|
+
pageSize: String(Math.min(maxResults, 1000)),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const contacts = [];
|
|
81
|
+
let pageToken = null;
|
|
82
|
+
|
|
83
|
+
do {
|
|
84
|
+
if (pageToken) params.set('pageToken', pageToken);
|
|
85
|
+
const data = await peopleFetch(config, `/people/me/connections?${params}`);
|
|
86
|
+
const connections = data.connections || [];
|
|
87
|
+
contacts.push(...connections.map(parseContact));
|
|
88
|
+
pageToken = data.nextPageToken || null;
|
|
89
|
+
} while (pageToken && contacts.length < maxResults);
|
|
90
|
+
|
|
91
|
+
return contacts.slice(0, maxResults);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get a single contact by resourceName (e.g. "people/c1234567890").
|
|
96
|
+
* @param {object} config
|
|
97
|
+
* @param {string} resourceName
|
|
98
|
+
* @returns {Promise<object>}
|
|
99
|
+
*/
|
|
100
|
+
export async function getContact(config, resourceName) {
|
|
101
|
+
const params = new URLSearchParams({
|
|
102
|
+
personFields: CONTACT_FIELDS_FULL,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const name = resourceName.startsWith('people/') ? resourceName : `people/${resourceName}`;
|
|
106
|
+
const data = await peopleFetch(config, `/${name}?${params}`);
|
|
107
|
+
return parseContact(data);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get all contacts that have a birthday set.
|
|
112
|
+
* @param {object} config
|
|
113
|
+
* @returns {Promise<Array>}
|
|
114
|
+
*/
|
|
115
|
+
export async function getBirthdays(config) {
|
|
116
|
+
const all = await listContacts(config, 1000);
|
|
117
|
+
return all.filter(c => c.birthday);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a new contact.
|
|
122
|
+
*/
|
|
123
|
+
export async function createContact(config, { name, email, phone, company, title, address }) {
|
|
124
|
+
const body = { names: [], emailAddresses: [], phoneNumbers: [], organizations: [], addresses: [] };
|
|
125
|
+
|
|
126
|
+
if (name) {
|
|
127
|
+
const parts = name.split(' ');
|
|
128
|
+
body.names.push({ givenName: parts[0], familyName: parts.slice(1).join(' ') || '' });
|
|
129
|
+
}
|
|
130
|
+
if (email) body.emailAddresses.push({ value: email });
|
|
131
|
+
if (phone) body.phoneNumbers.push({ value: phone });
|
|
132
|
+
if (company || title) body.organizations.push({ name: company || '', title: title || '' });
|
|
133
|
+
if (address) body.addresses.push({ formattedValue: address });
|
|
134
|
+
|
|
135
|
+
const data = await peopleFetch(config, '/people:createContact', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify(body),
|
|
139
|
+
});
|
|
140
|
+
return parseContact(data);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Update an existing contact.
|
|
145
|
+
*/
|
|
146
|
+
export async function updateContact(config, resourceName, fields) {
|
|
147
|
+
// First get the current contact to get etag
|
|
148
|
+
const current = await peopleFetch(config, `/${resourceName}?personFields=names,emailAddresses,phoneNumbers,organizations,addresses`);
|
|
149
|
+
|
|
150
|
+
const body = { etag: current.etag };
|
|
151
|
+
const updateFields = [];
|
|
152
|
+
|
|
153
|
+
if (fields.name !== undefined) {
|
|
154
|
+
const parts = (fields.name || '').split(' ');
|
|
155
|
+
body.names = [{ givenName: parts[0], familyName: parts.slice(1).join(' ') || '' }];
|
|
156
|
+
updateFields.push('names');
|
|
157
|
+
}
|
|
158
|
+
if (fields.email !== undefined) {
|
|
159
|
+
body.emailAddresses = [{ value: fields.email }];
|
|
160
|
+
updateFields.push('emailAddresses');
|
|
161
|
+
}
|
|
162
|
+
if (fields.phone !== undefined) {
|
|
163
|
+
body.phoneNumbers = [{ value: fields.phone }];
|
|
164
|
+
updateFields.push('phoneNumbers');
|
|
165
|
+
}
|
|
166
|
+
if (fields.company !== undefined || fields.title !== undefined) {
|
|
167
|
+
body.organizations = [{ name: fields.company || '', title: fields.title || '' }];
|
|
168
|
+
updateFields.push('organizations');
|
|
169
|
+
}
|
|
170
|
+
if (fields.address !== undefined) {
|
|
171
|
+
body.addresses = [{ formattedValue: fields.address }];
|
|
172
|
+
updateFields.push('addresses');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const data = await peopleFetch(config, `/${resourceName}:updateContact?updatePersonFields=${updateFields.join(',')}`, {
|
|
176
|
+
method: 'PATCH',
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
});
|
|
180
|
+
return parseContact(data);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Delete a contact.
|
|
185
|
+
*/
|
|
186
|
+
export async function deleteContact(config, resourceName) {
|
|
187
|
+
await peopleFetch(config, `/${resourceName}:deleteContact`, { method: 'DELETE' });
|
|
188
|
+
return { ok: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function parseContact(raw) {
|
|
194
|
+
if (!raw) return null;
|
|
195
|
+
|
|
196
|
+
const names = raw.names || [];
|
|
197
|
+
const primaryName = names.find(n => n.metadata?.primary) || names[0] || {};
|
|
198
|
+
|
|
199
|
+
const emailAddresses = raw.emailAddresses || [];
|
|
200
|
+
const primaryEmail = emailAddresses.find(e => e.metadata?.primary) || emailAddresses[0] || {};
|
|
201
|
+
|
|
202
|
+
const phoneNumbers = raw.phoneNumbers || [];
|
|
203
|
+
const primaryPhone = phoneNumbers.find(p => p.metadata?.primary) || phoneNumbers[0] || {};
|
|
204
|
+
|
|
205
|
+
const organizations = raw.organizations || [];
|
|
206
|
+
const primaryOrg = organizations.find(o => o.metadata?.primary) || organizations[0] || {};
|
|
207
|
+
|
|
208
|
+
const birthdays = raw.birthdays || [];
|
|
209
|
+
const primaryBirthday = birthdays.find(b => b.metadata?.primary) || birthdays[0];
|
|
210
|
+
|
|
211
|
+
const addresses = raw.addresses || [];
|
|
212
|
+
const primaryAddress = addresses.find(a => a.metadata?.primary) || addresses[0] || {};
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
resourceName: raw.resourceName || '',
|
|
216
|
+
name: primaryName.displayName || '',
|
|
217
|
+
firstName: primaryName.givenName || '',
|
|
218
|
+
lastName: primaryName.familyName || '',
|
|
219
|
+
email: primaryEmail.value || '',
|
|
220
|
+
emails: emailAddresses.map(e => e.value).filter(Boolean),
|
|
221
|
+
phone: primaryPhone.value || '',
|
|
222
|
+
phones: phoneNumbers.map(p => p.value).filter(Boolean),
|
|
223
|
+
company: primaryOrg.name || '',
|
|
224
|
+
title: primaryOrg.title || '',
|
|
225
|
+
birthday: formatBirthday(primaryBirthday),
|
|
226
|
+
address: formatAddress(primaryAddress),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatBirthday(birthday) {
|
|
231
|
+
if (!birthday?.date) return '';
|
|
232
|
+
const { year, month, day } = birthday.date;
|
|
233
|
+
const mm = String(month).padStart(2, '0');
|
|
234
|
+
const dd = String(day).padStart(2, '0');
|
|
235
|
+
if (year) return `${year}-${mm}-${dd}`;
|
|
236
|
+
return `${mm}-${dd}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatAddress(address) {
|
|
240
|
+
if (!address) return '';
|
|
241
|
+
if (address.formattedValue) return address.formattedValue;
|
|
242
|
+
const parts = [
|
|
243
|
+
address.streetAddress,
|
|
244
|
+
address.city,
|
|
245
|
+
address.region,
|
|
246
|
+
address.postalCode,
|
|
247
|
+
address.country,
|
|
248
|
+
].filter(Boolean);
|
|
249
|
+
return parts.join(', ');
|
|
250
|
+
}
|
|
@@ -22,6 +22,8 @@ const SCOPES = [
|
|
|
22
22
|
'https://www.googleapis.com/auth/calendar.events',
|
|
23
23
|
'https://www.googleapis.com/auth/drive.readonly',
|
|
24
24
|
'https://www.googleapis.com/auth/drive.metadata.readonly',
|
|
25
|
+
'https://www.googleapis.com/auth/contacts',
|
|
26
|
+
'https://www.googleapis.com/auth/tasks',
|
|
25
27
|
'https://www.googleapis.com/auth/userinfo.email',
|
|
26
28
|
].join(' ');
|
|
27
29
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Tasks API wrapper — zero dependencies.
|
|
3
|
+
* All calls via native fetch to Tasks REST API v1.
|
|
4
|
+
* Auto-refreshes tokens on 401.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getAccessToken } from './token-store.mjs';
|
|
8
|
+
|
|
9
|
+
const TASKS_BASE = 'https://tasks.googleapis.com/tasks/v1';
|
|
10
|
+
|
|
11
|
+
/** Authenticated fetch with auto-retry on 401 */
|
|
12
|
+
async function tasksFetch(config, urlPath, options = {}) {
|
|
13
|
+
const token = await getAccessToken(config);
|
|
14
|
+
const url = urlPath.startsWith('http') ? urlPath : `${TASKS_BASE}${urlPath}`;
|
|
15
|
+
|
|
16
|
+
let res = await fetch(url, {
|
|
17
|
+
...options,
|
|
18
|
+
headers: {
|
|
19
|
+
'Authorization': `Bearer ${token}`,
|
|
20
|
+
...options.headers,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (res.status === 401) {
|
|
25
|
+
const newToken = await getAccessToken(config);
|
|
26
|
+
res = await fetch(url, {
|
|
27
|
+
...options,
|
|
28
|
+
headers: {
|
|
29
|
+
...options.headers,
|
|
30
|
+
'Authorization': `Bearer ${newToken}`,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const err = await res.text();
|
|
37
|
+
throw new Error(`Tasks API ${res.status}: ${err}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// DELETE returns 204 No Content
|
|
41
|
+
if (res.status === 204) return null;
|
|
42
|
+
|
|
43
|
+
return res.json();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* List all task lists for the authenticated user.
|
|
48
|
+
* @param {object} config
|
|
49
|
+
* @returns {Promise<Array<{id: string, title: string, updated: string}>>}
|
|
50
|
+
*/
|
|
51
|
+
export async function listTaskLists(config) {
|
|
52
|
+
const data = await tasksFetch(config, '/users/@me/lists');
|
|
53
|
+
return (data.items || []).map(item => ({
|
|
54
|
+
id: item.id,
|
|
55
|
+
title: item.title || '(untitled)',
|
|
56
|
+
updated: item.updated || '',
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List tasks in a task list.
|
|
62
|
+
* @param {object} config
|
|
63
|
+
* @param {string} taskListId — task list ID, defaults to '@default'
|
|
64
|
+
* @returns {Promise<Array>}
|
|
65
|
+
*/
|
|
66
|
+
export async function listTasks(config, taskListId = '@default') {
|
|
67
|
+
const params = new URLSearchParams({
|
|
68
|
+
showCompleted: 'false',
|
|
69
|
+
maxResults: '100',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const data = await tasksFetch(config, `/lists/${encodeURIComponent(taskListId)}/tasks?${params}`);
|
|
73
|
+
return (data.items || []).map(item => parseTask(item, taskListId));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a new task.
|
|
78
|
+
* @param {object} config
|
|
79
|
+
* @param {string} taskListId
|
|
80
|
+
* @param {string} title
|
|
81
|
+
* @param {string} [notes]
|
|
82
|
+
* @param {string} [due] — RFC 3339 date-time string
|
|
83
|
+
* @returns {Promise<object>}
|
|
84
|
+
*/
|
|
85
|
+
export async function createTask(config, taskListId, title, notes, due) {
|
|
86
|
+
const body = { title };
|
|
87
|
+
if (notes) body.notes = notes;
|
|
88
|
+
if (due) body.due = due;
|
|
89
|
+
|
|
90
|
+
const data = await tasksFetch(config, `/lists/${encodeURIComponent(taskListId)}/tasks`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return parseTask(data, taskListId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mark a task as completed.
|
|
101
|
+
* @param {object} config
|
|
102
|
+
* @param {string} taskListId
|
|
103
|
+
* @param {string} taskId
|
|
104
|
+
* @returns {Promise<object>}
|
|
105
|
+
*/
|
|
106
|
+
export async function completeTask(config, taskListId, taskId) {
|
|
107
|
+
const data = await tasksFetch(
|
|
108
|
+
config,
|
|
109
|
+
`/lists/${encodeURIComponent(taskListId)}/tasks/${encodeURIComponent(taskId)}`,
|
|
110
|
+
{
|
|
111
|
+
method: 'PATCH',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ status: 'completed' }),
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return parseTask(data, taskListId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Delete a task.
|
|
122
|
+
* @param {object} config
|
|
123
|
+
* @param {string} taskListId
|
|
124
|
+
* @param {string} taskId
|
|
125
|
+
* @returns {Promise<void>}
|
|
126
|
+
*/
|
|
127
|
+
export async function deleteTask(config, taskListId, taskId) {
|
|
128
|
+
await tasksFetch(
|
|
129
|
+
config,
|
|
130
|
+
`/lists/${encodeURIComponent(taskListId)}/tasks/${encodeURIComponent(taskId)}`,
|
|
131
|
+
{ method: 'DELETE' },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
function parseTask(raw, listId) {
|
|
138
|
+
return {
|
|
139
|
+
id: raw.id,
|
|
140
|
+
title: raw.title || '',
|
|
141
|
+
notes: raw.notes || '',
|
|
142
|
+
status: raw.status || 'needsAction',
|
|
143
|
+
due: raw.due || '',
|
|
144
|
+
updated: raw.updated || '',
|
|
145
|
+
listId: listId || '',
|
|
146
|
+
};
|
|
147
|
+
}
|