@plosson/agentio 0.1.26 → 0.1.28
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 +213 -166
- package/package.json +1 -1
- package/src/auth/jira-oauth.ts +5 -23
- package/src/commands/config.ts +33 -12
- package/src/commands/discourse.ts +209 -0
- package/src/commands/gchat.ts +8 -49
- package/src/commands/gmail.ts +37 -58
- package/src/commands/jira.ts +12 -53
- package/src/commands/slack.ts +8 -49
- package/src/commands/status.ts +164 -0
- package/src/commands/telegram.ts +19 -60
- package/src/config/config-manager.ts +17 -1
- package/src/config/credentials.ts +2 -2
- package/src/index.ts +4 -0
- package/src/services/discourse/client.ts +287 -0
- package/src/services/gchat/client.ts +23 -1
- package/src/services/gmail/client.ts +145 -44
- package/src/services/jira/client.ts +44 -1
- package/src/services/slack/client.ts +9 -1
- package/src/services/telegram/client.ts +14 -1
- package/src/types/config.ts +3 -1
- package/src/types/discourse.ts +62 -0
- package/src/types/gmail.ts +1 -0
- package/src/types/service.ts +21 -0
- package/src/utils/output.ts +76 -0
- package/src/utils/profile-commands.ts +90 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { listProfiles, CONFIG_DIR } from '../config/config-manager';
|
|
3
|
+
import { getCredentials, setCredentials } from '../auth/token-store';
|
|
4
|
+
import { createGoogleAuth } from '../auth/token-manager';
|
|
5
|
+
import { refreshJiraToken } from '../auth/jira-oauth';
|
|
6
|
+
import { TelegramClient } from '../services/telegram/client';
|
|
7
|
+
import { GmailClient } from '../services/gmail/client';
|
|
8
|
+
import { JiraClient } from '../services/jira/client';
|
|
9
|
+
import { GChatClient } from '../services/gchat/client';
|
|
10
|
+
import { SlackClient } from '../services/slack/client';
|
|
11
|
+
import { DiscourseClient } from '../services/discourse/client';
|
|
12
|
+
import type { ServiceClient, ValidationResult } from '../types/service';
|
|
13
|
+
import type { ServiceName } from '../types/config';
|
|
14
|
+
import type { OAuthTokens } from '../types/tokens';
|
|
15
|
+
import type { TelegramCredentials } from '../types/telegram';
|
|
16
|
+
import type { JiraCredentials } from '../types/jira';
|
|
17
|
+
import type { GChatCredentials } from '../types/gchat';
|
|
18
|
+
import type { SlackCredentials } from '../types/slack';
|
|
19
|
+
import type { DiscourseCredentials } from '../types/discourse';
|
|
20
|
+
|
|
21
|
+
type GmailCredentials = OAuthTokens & { email?: string };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a ServiceClient for the given service and credentials.
|
|
25
|
+
* Handles token refresh for OAuth services before creating the client.
|
|
26
|
+
*/
|
|
27
|
+
async function createServiceClient(
|
|
28
|
+
service: ServiceName,
|
|
29
|
+
credentials: unknown,
|
|
30
|
+
profileName: string
|
|
31
|
+
): Promise<ServiceClient | null> {
|
|
32
|
+
switch (service) {
|
|
33
|
+
case 'gmail': {
|
|
34
|
+
const creds = credentials as GmailCredentials;
|
|
35
|
+
const auth = createGoogleAuth({
|
|
36
|
+
access_token: creds.access_token,
|
|
37
|
+
refresh_token: creds.refresh_token,
|
|
38
|
+
expiry_date: creds.expiry_date,
|
|
39
|
+
token_type: creds.token_type || 'Bearer',
|
|
40
|
+
scope: creds.scope,
|
|
41
|
+
});
|
|
42
|
+
return new GmailClient(auth);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case 'telegram': {
|
|
46
|
+
const creds = credentials as TelegramCredentials;
|
|
47
|
+
return new TelegramClient(creds.bot_token, creds.channel_id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case 'jira': {
|
|
51
|
+
let creds = credentials as JiraCredentials;
|
|
52
|
+
// Refresh token if expired or about to expire
|
|
53
|
+
const bufferTime = 5 * 60 * 1000;
|
|
54
|
+
if (creds.expiryDate && Date.now() + bufferTime >= creds.expiryDate) {
|
|
55
|
+
try {
|
|
56
|
+
const refreshed = await refreshJiraToken(creds.refreshToken);
|
|
57
|
+
creds = {
|
|
58
|
+
...creds,
|
|
59
|
+
accessToken: refreshed.accessToken,
|
|
60
|
+
refreshToken: refreshed.refreshToken,
|
|
61
|
+
expiryDate: Date.now() + refreshed.expiresIn * 1000,
|
|
62
|
+
};
|
|
63
|
+
await setCredentials('jira', profileName, creds);
|
|
64
|
+
} catch {
|
|
65
|
+
// Return a mock client that reports refresh failure
|
|
66
|
+
return {
|
|
67
|
+
validate: async () => ({
|
|
68
|
+
valid: false,
|
|
69
|
+
error: 'refresh token expired, re-authenticate',
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return new JiraClient(creds);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'gchat': {
|
|
78
|
+
const creds = credentials as GChatCredentials;
|
|
79
|
+
return new GChatClient(creds);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'slack': {
|
|
83
|
+
const creds = credentials as SlackCredentials;
|
|
84
|
+
return new SlackClient(creds);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case 'discourse': {
|
|
88
|
+
const creds = credentials as DiscourseCredentials;
|
|
89
|
+
return new DiscourseClient(creds);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
default:
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function registerStatusCommand(program: Command): void {
|
|
98
|
+
program
|
|
99
|
+
.command('status')
|
|
100
|
+
.description('Show configured profiles and credential status')
|
|
101
|
+
.option('--no-test', 'Skip credential testing')
|
|
102
|
+
.action(async (options) => {
|
|
103
|
+
try {
|
|
104
|
+
const allProfiles = await listProfiles();
|
|
105
|
+
const version = program.version();
|
|
106
|
+
|
|
107
|
+
console.log(`agentio v${version}`);
|
|
108
|
+
console.log(`Config: ${CONFIG_DIR}\n`);
|
|
109
|
+
|
|
110
|
+
let hasProfiles = false;
|
|
111
|
+
|
|
112
|
+
for (const { service, profiles, default: defaultProfile } of allProfiles) {
|
|
113
|
+
if (profiles.length === 0) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
hasProfiles = true;
|
|
118
|
+
const displayName = service.charAt(0).toUpperCase() + service.slice(1);
|
|
119
|
+
console.log(displayName);
|
|
120
|
+
|
|
121
|
+
for (const name of profiles) {
|
|
122
|
+
const marker = name === defaultProfile ? ' (default)' : '';
|
|
123
|
+
const credentials = await getCredentials(service, name);
|
|
124
|
+
|
|
125
|
+
if (!credentials) {
|
|
126
|
+
console.log(` ${name}${marker} ? no credentials`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (options.test === false) {
|
|
131
|
+
console.log(` ${name}${marker}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const client = await createServiceClient(service, credentials, name);
|
|
136
|
+
let result: ValidationResult;
|
|
137
|
+
|
|
138
|
+
if (client) {
|
|
139
|
+
result = await client.validate();
|
|
140
|
+
} else {
|
|
141
|
+
result = { valid: true, info: 'unknown service' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const status = result.valid ? 'ok' : 'invalid';
|
|
145
|
+
const statusMark = result.valid ? '+' : 'x';
|
|
146
|
+
const info = result.info ? ` ${result.info}` : '';
|
|
147
|
+
const error = result.error ? ` (${result.error})` : '';
|
|
148
|
+
|
|
149
|
+
console.log(` ${name}${marker} ${statusMark} ${status}${info}${error}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!hasProfiles) {
|
|
156
|
+
console.log('No profiles configured.');
|
|
157
|
+
console.log('Run: agentio <service> profile add');
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.error('Error:', error instanceof Error ? error.message : 'Unknown error');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
package/src/commands/telegram.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { setCredentials,
|
|
3
|
-
import { setProfile,
|
|
2
|
+
import { setCredentials, getCredentials } from '../auth/token-store';
|
|
3
|
+
import { setProfile, getProfile } from '../config/config-manager';
|
|
4
|
+
import { createProfileCommands } from '../utils/profile-commands';
|
|
4
5
|
import { TelegramClient } from '../services/telegram/client';
|
|
5
6
|
import { CliError, handleError } from '../utils/errors';
|
|
6
7
|
import { readStdin, prompt, resolveProfileName } from '../utils/stdin';
|
|
@@ -82,9 +83,11 @@ export function registerTelegramCommands(program: Command): void {
|
|
|
82
83
|
});
|
|
83
84
|
|
|
84
85
|
// Profile management
|
|
85
|
-
const profile = telegram
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const profile = createProfileCommands<TelegramCredentials>(telegram, {
|
|
87
|
+
service: 'telegram',
|
|
88
|
+
displayName: 'Telegram',
|
|
89
|
+
getExtraInfo: (credentials) => credentials?.channel_name ? ` - ${credentials.channel_name}` : '',
|
|
90
|
+
});
|
|
88
91
|
|
|
89
92
|
profile
|
|
90
93
|
.command('add')
|
|
@@ -94,16 +97,16 @@ export function registerTelegramCommands(program: Command): void {
|
|
|
94
97
|
try {
|
|
95
98
|
const profileName = await resolveProfileName('telegram', options.profile);
|
|
96
99
|
|
|
97
|
-
console.error('\
|
|
100
|
+
console.error('\nTelegram Bot Setup\n');
|
|
98
101
|
|
|
99
102
|
// Step 1: Create bot
|
|
100
103
|
console.error('Step 1: Create your bot');
|
|
101
104
|
console.error(' Open Telegram and message @BotFather');
|
|
102
|
-
console.error('
|
|
105
|
+
console.error(' -> https://t.me/BotFather\n');
|
|
103
106
|
console.error(' Send these commands:');
|
|
104
107
|
console.error(' /newbot');
|
|
105
|
-
console.error('
|
|
106
|
-
console.error('
|
|
108
|
+
console.error(' -> Enter a display name (e.g., "My Announcements Bot")');
|
|
109
|
+
console.error(' -> Enter a username ending in "bot" (e.g., "my_announce_bot")\n');
|
|
107
110
|
console.error(' BotFather will give you a token like:');
|
|
108
111
|
console.error(' 123456789:ABCdefGHIjklMNOpqrsTUVwxyz\n');
|
|
109
112
|
|
|
@@ -125,17 +128,17 @@ export function registerTelegramCommands(program: Command): void {
|
|
|
125
128
|
throw error;
|
|
126
129
|
}
|
|
127
130
|
|
|
128
|
-
console.error(`\
|
|
131
|
+
console.error(`\nBot verified: @${botInfo.username}\n`);
|
|
129
132
|
|
|
130
133
|
// Step 2: Add bot to channel
|
|
131
134
|
console.error('Step 2: Add bot to your channel');
|
|
132
135
|
console.error(' 1. Open your Telegram channel');
|
|
133
|
-
console.error(' 2. Go to Channel Settings
|
|
136
|
+
console.error(' 2. Go to Channel Settings -> Administrators');
|
|
134
137
|
console.error(` 3. Add @${botInfo.username} as admin with "Post Messages" permission\n`);
|
|
135
138
|
|
|
136
139
|
console.error(' How to find your channel ID:');
|
|
137
|
-
console.error('
|
|
138
|
-
console.error('
|
|
140
|
+
console.error(' - Public channel: Use @username (e.g., @mychannel)');
|
|
141
|
+
console.error(' - Private channel: Forward any message from the channel to @userinfobot');
|
|
139
142
|
console.error(' The bot will reply with the channel ID (starts with -100)\n');
|
|
140
143
|
|
|
141
144
|
const channelId = await prompt('? Enter channel ID: ');
|
|
@@ -162,8 +165,8 @@ export function registerTelegramCommands(program: Command): void {
|
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
const channelName = chatInfo.title || chatInfo.username || channelId;
|
|
165
|
-
console.error(`\
|
|
166
|
-
console.error('
|
|
168
|
+
console.error(`\nChannel verified: ${channelName}`);
|
|
169
|
+
console.error('Bot can post to this channel\n');
|
|
167
170
|
|
|
168
171
|
// Step 3: Optional customization tips
|
|
169
172
|
console.error('Step 3: Customize your bot (optional)');
|
|
@@ -182,54 +185,10 @@ export function registerTelegramCommands(program: Command): void {
|
|
|
182
185
|
await setProfile('telegram', profileName);
|
|
183
186
|
await setCredentials('telegram', profileName, credentials);
|
|
184
187
|
|
|
185
|
-
console.log(`\
|
|
188
|
+
console.log(`\nProfile "${profileName}" configured!`);
|
|
186
189
|
console.log(` Test with: agentio telegram send --profile ${profileName} "Hello world"`);
|
|
187
190
|
} catch (error) {
|
|
188
191
|
handleError(error);
|
|
189
192
|
}
|
|
190
193
|
});
|
|
191
|
-
|
|
192
|
-
profile
|
|
193
|
-
.command('list')
|
|
194
|
-
.description('List Telegram profiles')
|
|
195
|
-
.action(async () => {
|
|
196
|
-
try {
|
|
197
|
-
const result = await listProfiles('telegram');
|
|
198
|
-
const { profiles, default: defaultProfile } = result[0];
|
|
199
|
-
|
|
200
|
-
if (profiles.length === 0) {
|
|
201
|
-
console.log('No profiles configured');
|
|
202
|
-
} else {
|
|
203
|
-
for (const name of profiles) {
|
|
204
|
-
const marker = name === defaultProfile ? ' (default)' : '';
|
|
205
|
-
const credentials = await getCredentials<TelegramCredentials>('telegram', name);
|
|
206
|
-
const channelInfo = credentials?.channel_name ? ` - ${credentials.channel_name}` : '';
|
|
207
|
-
console.log(`${name}${marker}${channelInfo}`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
handleError(error);
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
profile
|
|
216
|
-
.command('remove')
|
|
217
|
-
.description('Remove a Telegram profile')
|
|
218
|
-
.requiredOption('--profile <name>', 'Profile name')
|
|
219
|
-
.action(async (options) => {
|
|
220
|
-
try {
|
|
221
|
-
const profileName = options.profile;
|
|
222
|
-
|
|
223
|
-
const removed = await removeProfile('telegram', profileName);
|
|
224
|
-
await removeCredentials('telegram', profileName);
|
|
225
|
-
|
|
226
|
-
if (removed) {
|
|
227
|
-
console.error(`Removed profile "${profileName}"`);
|
|
228
|
-
} else {
|
|
229
|
-
console.error(`Profile "${profileName}" not found`);
|
|
230
|
-
}
|
|
231
|
-
} catch (error) {
|
|
232
|
-
handleError(error);
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
194
|
}
|
|
@@ -109,13 +109,29 @@ export async function removeProfile(
|
|
|
109
109
|
return true;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export async function setDefault(
|
|
113
|
+
service: ServiceName,
|
|
114
|
+
profileName: string
|
|
115
|
+
): Promise<boolean> {
|
|
116
|
+
const config = await loadConfig();
|
|
117
|
+
|
|
118
|
+
const serviceProfiles = config.profiles[service] || [];
|
|
119
|
+
if (!serviceProfiles.includes(profileName)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
config.defaults[service] = profileName;
|
|
124
|
+
await saveConfig(config);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
export async function listProfiles(service?: ServiceName): Promise<{
|
|
113
129
|
service: ServiceName;
|
|
114
130
|
profiles: string[];
|
|
115
131
|
default?: string;
|
|
116
132
|
}[]> {
|
|
117
133
|
const config = await loadConfig();
|
|
118
|
-
const services: ServiceName[] = service ? [service] : ['gmail', 'gchat', 'jira', 'slack', 'telegram'];
|
|
134
|
+
const services: ServiceName[] = service ? [service] : ['gmail', 'gchat', 'jira', 'slack', 'telegram', 'discourse'];
|
|
119
135
|
|
|
120
136
|
return services.map((svc) => ({
|
|
121
137
|
service: svc,
|
|
@@ -13,8 +13,8 @@ export const GOOGLE_OAUTH_CONFIG = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
// JIRA/Atlassian OAuth credentials
|
|
16
|
-
const JIRA_CLIENT_ID = '
|
|
17
|
-
const JIRA_CLIENT_SECRET_ENC = '
|
|
16
|
+
const JIRA_CLIENT_ID = 'cVyhx1kQLRUef6gr50M9cTDke7ZPL4CN';
|
|
17
|
+
const JIRA_CLIENT_SECRET_ENC = 'cFN1vM5KVVVCIkv9YlE5O0rerKJUkr-CszeusEVxofAH7W0evcCidzAB_OdTygfAcq2LjbN1IXK7ZiBBl3XrBsIO7RfxSGcEfHWpSbbHWxnKPP6H2iOoQZbOfns';
|
|
18
18
|
|
|
19
19
|
export const JIRA_OAUTH_CONFIG = {
|
|
20
20
|
clientId: JIRA_CLIENT_ID,
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { registerGChatCommands } from './commands/gchat';
|
|
|
6
6
|
import { registerJiraCommands } from './commands/jira';
|
|
7
7
|
import { registerSlackCommands } from './commands/slack';
|
|
8
8
|
import { registerRssCommands } from './commands/rss';
|
|
9
|
+
import { registerDiscourseCommands } from './commands/discourse';
|
|
9
10
|
import { registerUpdateCommand } from './commands/update';
|
|
10
11
|
import { registerConfigCommands } from './commands/config';
|
|
11
12
|
import { registerClaudeCommands } from './commands/claude';
|
|
13
|
+
import { registerStatusCommand } from './commands/status';
|
|
12
14
|
|
|
13
15
|
declare const BUILD_VERSION: string | undefined;
|
|
14
16
|
|
|
@@ -33,8 +35,10 @@ registerGChatCommands(program);
|
|
|
33
35
|
registerJiraCommands(program);
|
|
34
36
|
registerSlackCommands(program);
|
|
35
37
|
registerRssCommands(program);
|
|
38
|
+
registerDiscourseCommands(program);
|
|
36
39
|
registerUpdateCommand(program);
|
|
37
40
|
registerConfigCommands(program);
|
|
38
41
|
registerClaudeCommands(program);
|
|
42
|
+
registerStatusCommand(program);
|
|
39
43
|
|
|
40
44
|
program.parse();
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiscourseCredentials,
|
|
3
|
+
DiscourseCategory,
|
|
4
|
+
DiscourseTopic,
|
|
5
|
+
DiscourseTopicDetail,
|
|
6
|
+
DiscoursePost,
|
|
7
|
+
DiscourseListOptions,
|
|
8
|
+
} from '../../types/discourse';
|
|
9
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
10
|
+
import { CliError, type ErrorCode } from '../../utils/errors';
|
|
11
|
+
|
|
12
|
+
interface DiscourseApiResponse {
|
|
13
|
+
errors?: string[];
|
|
14
|
+
error_type?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CategoryListResponse extends DiscourseApiResponse {
|
|
18
|
+
category_list: {
|
|
19
|
+
categories: RawCategory[];
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RawCategory {
|
|
24
|
+
id: number;
|
|
25
|
+
name: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
description: string;
|
|
28
|
+
topic_count: number;
|
|
29
|
+
post_count: number;
|
|
30
|
+
color: string;
|
|
31
|
+
parent_category_id?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TopicListResponse extends DiscourseApiResponse {
|
|
35
|
+
topic_list: {
|
|
36
|
+
topics: RawTopic[];
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RawTopic {
|
|
41
|
+
id: number;
|
|
42
|
+
title: string;
|
|
43
|
+
slug: string;
|
|
44
|
+
posts_count: number;
|
|
45
|
+
reply_count: number;
|
|
46
|
+
views: number;
|
|
47
|
+
like_count: number;
|
|
48
|
+
category_id: number;
|
|
49
|
+
created_at: string;
|
|
50
|
+
last_posted_at: string;
|
|
51
|
+
pinned: boolean;
|
|
52
|
+
closed: boolean;
|
|
53
|
+
archived: boolean;
|
|
54
|
+
posters?: Array<{ user_id: number; extras?: string }>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface TopicDetailResponse extends DiscourseApiResponse {
|
|
58
|
+
id: number;
|
|
59
|
+
title: string;
|
|
60
|
+
slug: string;
|
|
61
|
+
posts_count: number;
|
|
62
|
+
reply_count: number;
|
|
63
|
+
views: number;
|
|
64
|
+
like_count: number;
|
|
65
|
+
category_id: number;
|
|
66
|
+
created_at: string;
|
|
67
|
+
last_posted_at: string;
|
|
68
|
+
pinned: boolean;
|
|
69
|
+
closed: boolean;
|
|
70
|
+
archived: boolean;
|
|
71
|
+
post_stream: {
|
|
72
|
+
posts: RawPost[];
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface RawPost {
|
|
77
|
+
id: number;
|
|
78
|
+
username: string;
|
|
79
|
+
display_username?: string;
|
|
80
|
+
created_at: string;
|
|
81
|
+
updated_at: string;
|
|
82
|
+
post_number: number;
|
|
83
|
+
raw?: string;
|
|
84
|
+
cooked: string;
|
|
85
|
+
reply_count: number;
|
|
86
|
+
like_count: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class DiscourseClient implements ServiceClient {
|
|
90
|
+
private baseUrl: string;
|
|
91
|
+
private apiKey: string;
|
|
92
|
+
private username: string;
|
|
93
|
+
private categoryCache: Map<number, string> = new Map();
|
|
94
|
+
|
|
95
|
+
constructor(credentials: DiscourseCredentials) {
|
|
96
|
+
this.baseUrl = credentials.baseUrl.replace(/\/$/, '');
|
|
97
|
+
this.apiKey = credentials.apiKey;
|
|
98
|
+
this.username = credentials.username;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async validate(): Promise<ValidationResult> {
|
|
102
|
+
try {
|
|
103
|
+
await this.getCategories();
|
|
104
|
+
return { valid: true, info: this.baseUrl };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return {
|
|
107
|
+
valid: false,
|
|
108
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async request<T>(
|
|
114
|
+
method: string,
|
|
115
|
+
path: string,
|
|
116
|
+
body?: Record<string, unknown>
|
|
117
|
+
): Promise<T> {
|
|
118
|
+
const url = `${this.baseUrl}${path}`;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method,
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Api-Key': this.apiKey,
|
|
126
|
+
'Api-Username': this.username,
|
|
127
|
+
},
|
|
128
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const errorCode = this.getErrorCode(response.status);
|
|
133
|
+
const text = await response.text();
|
|
134
|
+
let message = `Discourse API error: ${response.status}`;
|
|
135
|
+
try {
|
|
136
|
+
const data = JSON.parse(text) as DiscourseApiResponse;
|
|
137
|
+
if (data.errors) {
|
|
138
|
+
message = data.errors.join(', ');
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
if (text) message = text;
|
|
142
|
+
}
|
|
143
|
+
throw new CliError(errorCode, message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (await response.json()) as T;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof CliError) throw error;
|
|
149
|
+
|
|
150
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
151
|
+
throw new CliError('NETWORK_ERROR', `Failed to connect to Discourse: ${message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private getErrorCode(status: number): ErrorCode {
|
|
156
|
+
if (status === 401) return 'AUTH_FAILED';
|
|
157
|
+
if (status === 403) return 'PERMISSION_DENIED';
|
|
158
|
+
if (status === 404) return 'NOT_FOUND';
|
|
159
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
160
|
+
return 'API_ERROR';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async getCategories(): Promise<DiscourseCategory[]> {
|
|
164
|
+
const data = await this.request<CategoryListResponse>('GET', '/categories.json');
|
|
165
|
+
|
|
166
|
+
const categories = data.category_list.categories.map((cat) => this.parseCategory(cat));
|
|
167
|
+
|
|
168
|
+
// Cache category names for topic listings
|
|
169
|
+
for (const cat of categories) {
|
|
170
|
+
this.categoryCache.set(cat.id, cat.name);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return categories;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private parseCategory(raw: RawCategory): DiscourseCategory {
|
|
177
|
+
return {
|
|
178
|
+
id: raw.id,
|
|
179
|
+
name: raw.name,
|
|
180
|
+
slug: raw.slug,
|
|
181
|
+
description: raw.description || '',
|
|
182
|
+
topicCount: raw.topic_count,
|
|
183
|
+
postCount: raw.post_count,
|
|
184
|
+
color: raw.color,
|
|
185
|
+
parentCategoryId: raw.parent_category_id,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async listTopics(options?: DiscourseListOptions): Promise<DiscourseTopic[]> {
|
|
190
|
+
let path: string;
|
|
191
|
+
|
|
192
|
+
if (options?.category) {
|
|
193
|
+
// Need to find category by slug
|
|
194
|
+
const categories = await this.getCategories();
|
|
195
|
+
const category = categories.find(
|
|
196
|
+
(c) => c.slug === options.category || c.name.toLowerCase() === options.category?.toLowerCase()
|
|
197
|
+
);
|
|
198
|
+
if (!category) {
|
|
199
|
+
throw new CliError('NOT_FOUND', `Category "${options.category}" not found`);
|
|
200
|
+
}
|
|
201
|
+
path = `/c/${category.slug}/${category.id}/l/latest.json`;
|
|
202
|
+
} else {
|
|
203
|
+
path = '/latest.json';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (options?.page && options.page > 0) {
|
|
207
|
+
path += `?page=${options.page}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const data = await this.request<TopicListResponse>('GET', path);
|
|
211
|
+
|
|
212
|
+
// Ensure category cache is populated
|
|
213
|
+
if (this.categoryCache.size === 0) {
|
|
214
|
+
await this.getCategories();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return data.topic_list.topics.map((topic) => this.parseTopic(topic));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private parseTopic(raw: RawTopic): DiscourseTopic {
|
|
221
|
+
return {
|
|
222
|
+
id: raw.id,
|
|
223
|
+
title: raw.title,
|
|
224
|
+
slug: raw.slug,
|
|
225
|
+
postsCount: raw.posts_count,
|
|
226
|
+
replyCount: raw.reply_count,
|
|
227
|
+
views: raw.views,
|
|
228
|
+
likeCount: raw.like_count,
|
|
229
|
+
categoryId: raw.category_id,
|
|
230
|
+
categoryName: this.categoryCache.get(raw.category_id),
|
|
231
|
+
createdAt: raw.created_at,
|
|
232
|
+
lastPostedAt: raw.last_posted_at,
|
|
233
|
+
pinned: raw.pinned,
|
|
234
|
+
closed: raw.closed,
|
|
235
|
+
archived: raw.archived,
|
|
236
|
+
posters: raw.posters?.map((p) => ({ userId: p.user_id })),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async getTopic(topicId: number): Promise<DiscourseTopicDetail> {
|
|
241
|
+
const data = await this.request<TopicDetailResponse>('GET', `/t/${topicId}.json`);
|
|
242
|
+
|
|
243
|
+
// Ensure category cache is populated
|
|
244
|
+
if (this.categoryCache.size === 0) {
|
|
245
|
+
await this.getCategories();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
id: data.id,
|
|
250
|
+
title: data.title,
|
|
251
|
+
slug: data.slug,
|
|
252
|
+
postsCount: data.posts_count,
|
|
253
|
+
replyCount: data.reply_count,
|
|
254
|
+
views: data.views,
|
|
255
|
+
likeCount: data.like_count,
|
|
256
|
+
categoryId: data.category_id,
|
|
257
|
+
categoryName: this.categoryCache.get(data.category_id),
|
|
258
|
+
createdAt: data.created_at,
|
|
259
|
+
lastPostedAt: data.last_posted_at,
|
|
260
|
+
pinned: data.pinned,
|
|
261
|
+
closed: data.closed,
|
|
262
|
+
archived: data.archived,
|
|
263
|
+
posts: data.post_stream.posts.map((p) => this.parsePost(p)),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private parsePost(raw: RawPost): DiscoursePost {
|
|
268
|
+
return {
|
|
269
|
+
id: raw.id,
|
|
270
|
+
username: raw.username,
|
|
271
|
+
displayName: raw.display_username,
|
|
272
|
+
createdAt: raw.created_at,
|
|
273
|
+
updatedAt: raw.updated_at,
|
|
274
|
+
postNumber: raw.post_number,
|
|
275
|
+
raw: raw.raw,
|
|
276
|
+
cooked: raw.cooked,
|
|
277
|
+
replyCount: raw.reply_count,
|
|
278
|
+
likeCount: raw.like_count,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async validateCredentials(): Promise<{ username: string }> {
|
|
283
|
+
// Try to fetch categories as a validation check
|
|
284
|
+
await this.getCategories();
|
|
285
|
+
return { username: this.username };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -2,6 +2,7 @@ import { google } from 'googleapis';
|
|
|
2
2
|
import type { chat_v1 } from 'googleapis';
|
|
3
3
|
import { CliError } from '../../utils/errors';
|
|
4
4
|
import type { ErrorCode } from '../../utils/errors';
|
|
5
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
5
6
|
import { GOOGLE_OAUTH_CONFIG } from '../../config/credentials';
|
|
6
7
|
import type {
|
|
7
8
|
GChatCredentials,
|
|
@@ -14,13 +15,34 @@ import type {
|
|
|
14
15
|
GChatMessage,
|
|
15
16
|
} from '../../types/gchat';
|
|
16
17
|
|
|
17
|
-
export class GChatClient {
|
|
18
|
+
export class GChatClient implements ServiceClient {
|
|
18
19
|
private credentials: GChatCredentials;
|
|
19
20
|
|
|
20
21
|
constructor(credentials: GChatCredentials) {
|
|
21
22
|
this.credentials = credentials;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
async validate(): Promise<ValidationResult> {
|
|
26
|
+
if (this.credentials.type === 'webhook') {
|
|
27
|
+
// Cannot validate webhooks without sending a message
|
|
28
|
+
return { valid: true, info: 'webhook (not testable)' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const oauthCreds = this.credentials as GChatOAuthCredentials;
|
|
33
|
+
const auth = this.createOAuthClient(oauthCreds);
|
|
34
|
+
const chat = google.chat({ version: 'v1', auth });
|
|
35
|
+
await chat.spaces.list({ pageSize: 1 });
|
|
36
|
+
return { valid: true, info: 'oauth' };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
39
|
+
if (message.includes('invalid_grant') || message.includes('Token has been expired or revoked')) {
|
|
40
|
+
return { valid: false, error: 'refresh token expired, re-authenticate' };
|
|
41
|
+
}
|
|
42
|
+
return { valid: false, error: message };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
async send(options: GChatSendOptions & { spaceId?: string }): Promise<GChatSendResult> {
|
|
25
47
|
if (this.credentials.type === 'webhook') {
|
|
26
48
|
return this.sendViaWebhook(options);
|