agentgate 0.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/README.md +216 -0
- package/package.json +63 -0
- package/public/favicon.svg +48 -0
- package/public/icons/bluesky.svg +1 -0
- package/public/icons/fitbit.svg +16 -0
- package/public/icons/github.svg +1 -0
- package/public/icons/google-calendar.svg +1 -0
- package/public/icons/jira.svg +1 -0
- package/public/icons/linkedin.svg +1 -0
- package/public/icons/mastodon.svg +1 -0
- package/public/icons/reddit.svg +1 -0
- package/public/icons/youtube.svg +1 -0
- package/public/logo.svg +52 -0
- package/public/style.css +584 -0
- package/src/cli.js +77 -0
- package/src/index.js +344 -0
- package/src/lib/db.js +325 -0
- package/src/lib/hsyncManager.js +57 -0
- package/src/lib/queueExecutor.js +362 -0
- package/src/routes/bluesky.js +130 -0
- package/src/routes/calendar.js +120 -0
- package/src/routes/fitbit.js +127 -0
- package/src/routes/github.js +72 -0
- package/src/routes/jira.js +77 -0
- package/src/routes/linkedin.js +137 -0
- package/src/routes/mastodon.js +91 -0
- package/src/routes/queue.js +186 -0
- package/src/routes/reddit.js +138 -0
- package/src/routes/ui/bluesky.js +66 -0
- package/src/routes/ui/calendar.js +120 -0
- package/src/routes/ui/fitbit.js +122 -0
- package/src/routes/ui/github.js +60 -0
- package/src/routes/ui/index.js +35 -0
- package/src/routes/ui/jira.js +72 -0
- package/src/routes/ui/linkedin.js +120 -0
- package/src/routes/ui/mastodon.js +140 -0
- package/src/routes/ui/reddit.js +120 -0
- package/src/routes/ui/youtube.js +120 -0
- package/src/routes/ui.js +1077 -0
- package/src/routes/youtube.js +119 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
|
|
2
|
+
|
|
3
|
+
export function registerRoutes(router, baseUrl) {
|
|
4
|
+
router.post('/linkedin/setup', (req, res) => {
|
|
5
|
+
const { accountName, clientId, clientSecret } = req.body;
|
|
6
|
+
if (!accountName || !clientId || !clientSecret) {
|
|
7
|
+
return res.status(400).send('Account name, client ID, and secret required');
|
|
8
|
+
}
|
|
9
|
+
setAccountCredentials('linkedin', accountName, { clientId, clientSecret });
|
|
10
|
+
|
|
11
|
+
const redirectUri = `${baseUrl}/ui/linkedin/callback`;
|
|
12
|
+
const scope = 'openid profile email r_liteprofile';
|
|
13
|
+
const state = `agentgate_linkedin_${accountName}`;
|
|
14
|
+
|
|
15
|
+
const authUrl = 'https://www.linkedin.com/oauth/v2/authorization?' +
|
|
16
|
+
`response_type=code&client_id=${clientId}&` +
|
|
17
|
+
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
|
18
|
+
`scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}`;
|
|
19
|
+
|
|
20
|
+
res.redirect(authUrl);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.get('/linkedin/callback', async (req, res) => {
|
|
24
|
+
const { code, error, error_description, state } = req.query;
|
|
25
|
+
if (error) {
|
|
26
|
+
return res.status(400).send(`LinkedIn OAuth error: ${error} - ${error_description}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const accountName = state?.replace('agentgate_linkedin_', '') || 'default';
|
|
30
|
+
const creds = getAccountCredentials('linkedin', accountName);
|
|
31
|
+
if (!creds) {
|
|
32
|
+
return res.status(400).send('LinkedIn account not found. Please try setup again.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const redirectUri = `${baseUrl}/ui/linkedin/callback`;
|
|
37
|
+
|
|
38
|
+
const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
42
|
+
},
|
|
43
|
+
body: new URLSearchParams({
|
|
44
|
+
grant_type: 'authorization_code',
|
|
45
|
+
code,
|
|
46
|
+
redirect_uri: redirectUri,
|
|
47
|
+
client_id: creds.clientId,
|
|
48
|
+
client_secret: creds.clientSecret
|
|
49
|
+
})
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const tokens = await response.json();
|
|
53
|
+
if (tokens.error) {
|
|
54
|
+
return res.status(400).send(`LinkedIn token error: ${tokens.error} - ${tokens.error_description}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setAccountCredentials('linkedin', accountName, {
|
|
58
|
+
...creds,
|
|
59
|
+
accessToken: tokens.access_token,
|
|
60
|
+
refreshToken: tokens.refresh_token,
|
|
61
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
res.redirect('/ui');
|
|
65
|
+
} catch (err) {
|
|
66
|
+
res.status(500).send(`LinkedIn OAuth failed: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
router.post('/linkedin/delete', (req, res) => {
|
|
71
|
+
const { accountName } = req.body;
|
|
72
|
+
deleteAccount('linkedin', accountName);
|
|
73
|
+
res.redirect('/ui');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderCard(accounts, baseUrl) {
|
|
78
|
+
const serviceAccounts = accounts.filter(a => a.service === 'linkedin');
|
|
79
|
+
|
|
80
|
+
const renderAccounts = () => {
|
|
81
|
+
if (serviceAccounts.length === 0) return '';
|
|
82
|
+
return serviceAccounts.map(acc => `
|
|
83
|
+
<div class="account-item">
|
|
84
|
+
<span><strong>${acc.name}</strong></span>
|
|
85
|
+
<form method="POST" action="/ui/linkedin/delete" style="margin:0;">
|
|
86
|
+
<input type="hidden" name="accountName" value="${acc.name}">
|
|
87
|
+
<button type="submit" class="btn-sm btn-danger">Remove</button>
|
|
88
|
+
</form>
|
|
89
|
+
</div>
|
|
90
|
+
`).join('');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return `
|
|
94
|
+
<div class="card">
|
|
95
|
+
<div class="service-header">
|
|
96
|
+
<img class="service-icon" src="/public/icons/linkedin.svg" alt="LinkedIn">
|
|
97
|
+
<h3>LinkedIn</h3>
|
|
98
|
+
</div>
|
|
99
|
+
${renderAccounts()}
|
|
100
|
+
<details>
|
|
101
|
+
<summary>Add LinkedIn Account</summary>
|
|
102
|
+
<div style="margin-top: 15px;">
|
|
103
|
+
<p class="help">Create an app at <a href="https://www.linkedin.com/developers/apps" target="_blank">LinkedIn Developers</a>. Request "Sign In with LinkedIn using OpenID Connect" product.</p>
|
|
104
|
+
<p class="help">Redirect URL: <span class="copyable">${baseUrl}/ui/linkedin/callback <button type="button" class="copy-btn" onclick="copyText('${baseUrl}/ui/linkedin/callback', this)">Copy</button></span></p>
|
|
105
|
+
<form method="POST" action="/ui/linkedin/setup">
|
|
106
|
+
<label>Account Name</label>
|
|
107
|
+
<input type="text" name="accountName" placeholder="personal, business, etc." required>
|
|
108
|
+
<label>Client ID</label>
|
|
109
|
+
<input type="text" name="clientId" placeholder="LinkedIn client ID" required>
|
|
110
|
+
<label>Client Secret</label>
|
|
111
|
+
<input type="password" name="clientSecret" placeholder="LinkedIn client secret" required>
|
|
112
|
+
<button type="submit" class="btn-primary">Add Account</button>
|
|
113
|
+
</form>
|
|
114
|
+
</div>
|
|
115
|
+
</details>
|
|
116
|
+
</div>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const serviceName = 'linkedin';
|
|
120
|
+
export const displayName = 'LinkedIn';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
|
|
2
|
+
|
|
3
|
+
export function registerRoutes(router, baseUrl) {
|
|
4
|
+
router.post('/mastodon/setup', async (req, res) => {
|
|
5
|
+
const { accountName, instance } = req.body;
|
|
6
|
+
if (!accountName || !instance) {
|
|
7
|
+
return res.status(400).send('Account name and instance required');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const cleanInstance = instance.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(`https://${cleanInstance}/api/v1/apps`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
client_name: 'agentgate',
|
|
18
|
+
redirect_uris: `${baseUrl}/ui/mastodon/callback`,
|
|
19
|
+
scopes: 'read',
|
|
20
|
+
website: baseUrl
|
|
21
|
+
})
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
return res.status(400).send(`Failed to register app with ${cleanInstance}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const app = await response.json();
|
|
29
|
+
|
|
30
|
+
setAccountCredentials('mastodon', accountName, {
|
|
31
|
+
instance: cleanInstance,
|
|
32
|
+
clientId: app.client_id,
|
|
33
|
+
clientSecret: app.client_secret
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const authUrl = `https://${cleanInstance}/oauth/authorize?` +
|
|
37
|
+
`client_id=${app.client_id}&response_type=code&` +
|
|
38
|
+
`redirect_uri=${encodeURIComponent(`${baseUrl}/ui/mastodon/callback`)}&` +
|
|
39
|
+
`scope=read&state=${encodeURIComponent(`agentgate_mastodon_${accountName}`)}`;
|
|
40
|
+
|
|
41
|
+
res.redirect(authUrl);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
res.status(500).send(`Mastodon setup failed: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
router.get('/mastodon/callback', async (req, res) => {
|
|
48
|
+
const { code, error, state } = req.query;
|
|
49
|
+
if (error) {
|
|
50
|
+
return res.status(400).send(`Mastodon OAuth error: ${error}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const accountName = state?.replace('agentgate_mastodon_', '') || 'default';
|
|
54
|
+
const creds = getAccountCredentials('mastodon', accountName);
|
|
55
|
+
if (!creds) {
|
|
56
|
+
return res.status(400).send('Mastodon account not found. Please try setup again.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(`https://${creds.instance}/oauth/token`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
client_id: creds.clientId,
|
|
65
|
+
client_secret: creds.clientSecret,
|
|
66
|
+
redirect_uri: `${baseUrl}/ui/mastodon/callback`,
|
|
67
|
+
grant_type: 'authorization_code',
|
|
68
|
+
code,
|
|
69
|
+
scope: 'read'
|
|
70
|
+
})
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const tokens = await response.json();
|
|
74
|
+
if (tokens.error) {
|
|
75
|
+
return res.status(400).send(`Mastodon token error: ${tokens.error}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setAccountCredentials('mastodon', accountName, {
|
|
79
|
+
...creds,
|
|
80
|
+
accessToken: tokens.access_token
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
res.redirect('/ui');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
res.status(500).send(`Mastodon OAuth failed: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
router.post('/mastodon/delete', (req, res) => {
|
|
90
|
+
const { accountName } = req.body;
|
|
91
|
+
deleteAccount('mastodon', accountName);
|
|
92
|
+
res.redirect('/ui');
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderCard(accounts, _baseUrl) {
|
|
97
|
+
const serviceAccounts = accounts.filter(a => a.service === 'mastodon');
|
|
98
|
+
|
|
99
|
+
const renderAccounts = () => {
|
|
100
|
+
if (serviceAccounts.length === 0) return '';
|
|
101
|
+
return serviceAccounts.map(acc => {
|
|
102
|
+
const creds = getAccountCredentials('mastodon', acc.name);
|
|
103
|
+
const info = creds?.instance ? `${acc.name} @${creds.instance}` : acc.name;
|
|
104
|
+
return `
|
|
105
|
+
<div class="account-item">
|
|
106
|
+
<span><strong>${info}</strong></span>
|
|
107
|
+
<form method="POST" action="/ui/mastodon/delete" style="margin:0;">
|
|
108
|
+
<input type="hidden" name="accountName" value="${acc.name}">
|
|
109
|
+
<button type="submit" class="btn-sm btn-danger">Remove</button>
|
|
110
|
+
</form>
|
|
111
|
+
</div>
|
|
112
|
+
`;
|
|
113
|
+
}).join('');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return `
|
|
117
|
+
<div class="card">
|
|
118
|
+
<div class="service-header">
|
|
119
|
+
<img class="service-icon" src="/public/icons/mastodon.svg" alt="Mastodon">
|
|
120
|
+
<h3>Mastodon</h3>
|
|
121
|
+
</div>
|
|
122
|
+
${renderAccounts()}
|
|
123
|
+
<details>
|
|
124
|
+
<summary>Add Mastodon Account</summary>
|
|
125
|
+
<div style="margin-top: 15px;">
|
|
126
|
+
<p class="help">Enter your Mastodon instance (e.g., fosstodon.org, mastodon.social)</p>
|
|
127
|
+
<form method="POST" action="/ui/mastodon/setup">
|
|
128
|
+
<label>Account Name</label>
|
|
129
|
+
<input type="text" name="accountName" placeholder="main, tech, etc." required>
|
|
130
|
+
<label>Instance</label>
|
|
131
|
+
<input type="text" name="instance" placeholder="fosstodon.org" required>
|
|
132
|
+
<button type="submit" class="btn-primary">Add Account</button>
|
|
133
|
+
</form>
|
|
134
|
+
</div>
|
|
135
|
+
</details>
|
|
136
|
+
</div>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const serviceName = 'mastodon';
|
|
140
|
+
export const displayName = 'Mastodon';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
|
|
2
|
+
|
|
3
|
+
export function registerRoutes(router, baseUrl) {
|
|
4
|
+
router.post('/reddit/setup', (req, res) => {
|
|
5
|
+
const { accountName, clientId, clientSecret } = req.body;
|
|
6
|
+
if (!accountName || !clientId || !clientSecret) {
|
|
7
|
+
return res.status(400).send('Account name, client ID, and secret required');
|
|
8
|
+
}
|
|
9
|
+
setAccountCredentials('reddit', accountName, { clientId, clientSecret });
|
|
10
|
+
|
|
11
|
+
const redirectUri = `${baseUrl}/ui/reddit/callback`;
|
|
12
|
+
const scope = 'read identity';
|
|
13
|
+
const state = `agentgate_reddit_${accountName}`;
|
|
14
|
+
|
|
15
|
+
const authUrl = 'https://www.reddit.com/api/v1/authorize?' +
|
|
16
|
+
`client_id=${clientId}&response_type=code&` +
|
|
17
|
+
`state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
|
18
|
+
`duration=permanent&scope=${encodeURIComponent(scope)}`;
|
|
19
|
+
|
|
20
|
+
res.redirect(authUrl);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.get('/reddit/callback', async (req, res) => {
|
|
24
|
+
const { code, error, state } = req.query;
|
|
25
|
+
if (error) {
|
|
26
|
+
return res.status(400).send(`Reddit OAuth error: ${error}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const accountName = state?.replace('agentgate_reddit_', '') || 'default';
|
|
30
|
+
const creds = getAccountCredentials('reddit', accountName);
|
|
31
|
+
if (!creds) {
|
|
32
|
+
return res.status(400).send('Reddit account not found. Please try setup again.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const basicAuth = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
|
|
37
|
+
const redirectUri = `${baseUrl}/ui/reddit/callback`;
|
|
38
|
+
|
|
39
|
+
const response = await fetch('https://www.reddit.com/api/v1/access_token', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: {
|
|
42
|
+
'Authorization': `Basic ${basicAuth}`,
|
|
43
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
44
|
+
},
|
|
45
|
+
body: new URLSearchParams({
|
|
46
|
+
grant_type: 'authorization_code',
|
|
47
|
+
code,
|
|
48
|
+
redirect_uri: redirectUri
|
|
49
|
+
})
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const tokens = await response.json();
|
|
53
|
+
if (tokens.error) {
|
|
54
|
+
return res.status(400).send(`Reddit token error: ${tokens.error}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setAccountCredentials('reddit', accountName, {
|
|
58
|
+
...creds,
|
|
59
|
+
accessToken: tokens.access_token,
|
|
60
|
+
refreshToken: tokens.refresh_token,
|
|
61
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
res.redirect('/ui');
|
|
65
|
+
} catch (err) {
|
|
66
|
+
res.status(500).send(`Reddit OAuth failed: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
router.post('/reddit/delete', (req, res) => {
|
|
71
|
+
const { accountName } = req.body;
|
|
72
|
+
deleteAccount('reddit', accountName);
|
|
73
|
+
res.redirect('/ui');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderCard(accounts, baseUrl) {
|
|
78
|
+
const serviceAccounts = accounts.filter(a => a.service === 'reddit');
|
|
79
|
+
|
|
80
|
+
const renderAccounts = () => {
|
|
81
|
+
if (serviceAccounts.length === 0) return '';
|
|
82
|
+
return serviceAccounts.map(acc => `
|
|
83
|
+
<div class="account-item">
|
|
84
|
+
<span><strong>${acc.name}</strong></span>
|
|
85
|
+
<form method="POST" action="/ui/reddit/delete" style="margin:0;">
|
|
86
|
+
<input type="hidden" name="accountName" value="${acc.name}">
|
|
87
|
+
<button type="submit" class="btn-sm btn-danger">Remove</button>
|
|
88
|
+
</form>
|
|
89
|
+
</div>
|
|
90
|
+
`).join('');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return `
|
|
94
|
+
<div class="card">
|
|
95
|
+
<div class="service-header">
|
|
96
|
+
<img class="service-icon" src="/public/icons/reddit.svg" alt="Reddit">
|
|
97
|
+
<h3>Reddit</h3>
|
|
98
|
+
</div>
|
|
99
|
+
${renderAccounts()}
|
|
100
|
+
<details>
|
|
101
|
+
<summary>Add Reddit Account</summary>
|
|
102
|
+
<div style="margin-top: 15px;">
|
|
103
|
+
<p class="help">Create an app at <a href="https://www.reddit.com/prefs/apps" target="_blank">reddit.com/prefs/apps</a> (select "web app")</p>
|
|
104
|
+
<p class="help">Redirect URI: <span class="copyable">${baseUrl}/ui/reddit/callback <button type="button" class="copy-btn" onclick="copyText('${baseUrl}/ui/reddit/callback', this)">Copy</button></span></p>
|
|
105
|
+
<form method="POST" action="/ui/reddit/setup">
|
|
106
|
+
<label>Account Name</label>
|
|
107
|
+
<input type="text" name="accountName" placeholder="main, throwaway, etc." required>
|
|
108
|
+
<label>Client ID</label>
|
|
109
|
+
<input type="text" name="clientId" placeholder="Reddit client ID" required>
|
|
110
|
+
<label>Client Secret</label>
|
|
111
|
+
<input type="password" name="clientSecret" placeholder="Reddit client secret" required>
|
|
112
|
+
<button type="submit" class="btn-primary">Add Account</button>
|
|
113
|
+
</form>
|
|
114
|
+
</div>
|
|
115
|
+
</details>
|
|
116
|
+
</div>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const serviceName = 'reddit';
|
|
120
|
+
export const displayName = 'Reddit';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
|
|
2
|
+
|
|
3
|
+
export function registerRoutes(router, baseUrl) {
|
|
4
|
+
router.post('/youtube/setup', (req, res) => {
|
|
5
|
+
const { accountName, clientId, clientSecret } = req.body;
|
|
6
|
+
if (!accountName || !clientId || !clientSecret) {
|
|
7
|
+
return res.status(400).send('Account name, client ID, and secret required');
|
|
8
|
+
}
|
|
9
|
+
setAccountCredentials('youtube', accountName, { clientId, clientSecret });
|
|
10
|
+
|
|
11
|
+
const redirectUri = `${baseUrl}/ui/youtube/callback`;
|
|
12
|
+
const scope = 'https://www.googleapis.com/auth/youtube.readonly';
|
|
13
|
+
const state = `agentgate_youtube_${accountName}`;
|
|
14
|
+
|
|
15
|
+
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' +
|
|
16
|
+
`client_id=${clientId}&response_type=code&` +
|
|
17
|
+
`state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
|
18
|
+
`scope=${encodeURIComponent(scope)}&access_type=offline&prompt=consent`;
|
|
19
|
+
|
|
20
|
+
res.redirect(authUrl);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.get('/youtube/callback', async (req, res) => {
|
|
24
|
+
const { code, error, state } = req.query;
|
|
25
|
+
if (error) {
|
|
26
|
+
return res.status(400).send(`YouTube OAuth error: ${error}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const accountName = state?.replace('agentgate_youtube_', '') || 'default';
|
|
30
|
+
const creds = getAccountCredentials('youtube', accountName);
|
|
31
|
+
if (!creds) {
|
|
32
|
+
return res.status(400).send('YouTube account not found. Please try setup again.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const redirectUri = `${baseUrl}/ui/youtube/callback`;
|
|
37
|
+
|
|
38
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
42
|
+
},
|
|
43
|
+
body: new URLSearchParams({
|
|
44
|
+
client_id: creds.clientId,
|
|
45
|
+
client_secret: creds.clientSecret,
|
|
46
|
+
code,
|
|
47
|
+
grant_type: 'authorization_code',
|
|
48
|
+
redirect_uri: redirectUri
|
|
49
|
+
})
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const tokens = await response.json();
|
|
53
|
+
if (tokens.error) {
|
|
54
|
+
return res.status(400).send(`YouTube token error: ${tokens.error}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setAccountCredentials('youtube', accountName, {
|
|
58
|
+
...creds,
|
|
59
|
+
accessToken: tokens.access_token,
|
|
60
|
+
refreshToken: tokens.refresh_token,
|
|
61
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
res.redirect('/ui');
|
|
65
|
+
} catch (err) {
|
|
66
|
+
res.status(500).send(`YouTube OAuth failed: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
router.post('/youtube/delete', (req, res) => {
|
|
71
|
+
const { accountName } = req.body;
|
|
72
|
+
deleteAccount('youtube', accountName);
|
|
73
|
+
res.redirect('/ui');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderCard(accounts, baseUrl) {
|
|
78
|
+
const serviceAccounts = accounts.filter(a => a.service === 'youtube');
|
|
79
|
+
|
|
80
|
+
const renderAccounts = () => {
|
|
81
|
+
if (serviceAccounts.length === 0) return '';
|
|
82
|
+
return serviceAccounts.map(acc => `
|
|
83
|
+
<div class="account-item">
|
|
84
|
+
<span><strong>${acc.name}</strong></span>
|
|
85
|
+
<form method="POST" action="/ui/youtube/delete" style="margin:0;">
|
|
86
|
+
<input type="hidden" name="accountName" value="${acc.name}">
|
|
87
|
+
<button type="submit" class="btn-sm btn-danger">Remove</button>
|
|
88
|
+
</form>
|
|
89
|
+
</div>
|
|
90
|
+
`).join('');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return `
|
|
94
|
+
<div class="card">
|
|
95
|
+
<div class="service-header">
|
|
96
|
+
<img class="service-icon" src="/public/icons/youtube.svg" alt="YouTube">
|
|
97
|
+
<h3>YouTube</h3>
|
|
98
|
+
</div>
|
|
99
|
+
${renderAccounts()}
|
|
100
|
+
<details>
|
|
101
|
+
<summary>Add YouTube Account</summary>
|
|
102
|
+
<div style="margin-top: 15px;">
|
|
103
|
+
<p class="help">Use the same Google Cloud Console project. Enable the YouTube Data API v3.</p>
|
|
104
|
+
<p class="help">Redirect URI: <span class="copyable">${baseUrl}/ui/youtube/callback <button type="button" class="copy-btn" onclick="copyText('${baseUrl}/ui/youtube/callback', this)">Copy</button></span></p>
|
|
105
|
+
<form method="POST" action="/ui/youtube/setup">
|
|
106
|
+
<label>Account Name</label>
|
|
107
|
+
<input type="text" name="accountName" placeholder="main, brand, etc." required>
|
|
108
|
+
<label>Client ID</label>
|
|
109
|
+
<input type="text" name="clientId" placeholder="xxxxxxxx.apps.googleusercontent.com" required>
|
|
110
|
+
<label>Client Secret</label>
|
|
111
|
+
<input type="password" name="clientSecret" placeholder="Google client secret" required>
|
|
112
|
+
<button type="submit" class="btn-primary">Add Account</button>
|
|
113
|
+
</form>
|
|
114
|
+
</div>
|
|
115
|
+
</details>
|
|
116
|
+
</div>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const serviceName = 'youtube';
|
|
120
|
+
export const displayName = 'YouTube';
|