@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.
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { setCredentials, removeCredentials, getCredentials } from '../auth/token-store';
3
- import { setProfile, removeProfile, listProfiles, getProfile } from '../config/config-manager';
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
- .command('profile')
87
- .description('Manage Telegram profiles');
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('\n📱 Telegram Bot Setup\n');
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(' https://t.me/BotFather\n');
105
+ console.error(' -> https://t.me/BotFather\n');
103
106
  console.error(' Send these commands:');
104
107
  console.error(' /newbot');
105
- console.error(' Enter a display name (e.g., "My Announcements Bot")');
106
- console.error(' Enter a username ending in "bot" (e.g., "my_announce_bot")\n');
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(`\n✓ Bot verified: @${botInfo.username}\n`);
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 Administrators');
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(' Public channel: Use @username (e.g., @mychannel)');
138
- console.error(' Private channel: Forward any message from the channel to @userinfobot');
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(`\n✓ Channel verified: ${channelName}`);
166
- console.error('Bot can post to this channel\n');
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(`\n✅ Profile "${profileName}" configured!`);
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 = '7408S0MZKdYnsiz0KXlUT15Lb69k7y0e';
17
- const JIRA_CLIENT_SECRET_ENC = 'C0J5Cyvhfl_UHCvmY9BfvKa_gzqdaa5mb3FwzRmq-YApJdAoBAKmeCl3xmcYLRCFdMvQLWVmifTkryoB0bmhVWX6eRTzoj1q4iyyTxZzT0yff0pYvLguHzp9Y4U';
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);