@plosson/agentio 0.1.26 → 0.1.27
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 +47 -31
- package/package.json +1 -1
- package/src/commands/discourse.ts +248 -0
- package/src/commands/gmail.ts +27 -7
- package/src/index.ts +2 -0
- package/src/services/discourse/client.ts +273 -0
- package/src/services/gmail/client.ts +129 -43
- package/src/types/config.ts +3 -1
- package/src/types/discourse.ts +62 -0
- package/src/types/gmail.ts +1 -0
- package/src/utils/output.ts +76 -0
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ Download from [GitHub Releases](https://github.com/plosson/agentio/releases/late
|
|
|
72
72
|
| Google Chat | Available | `send`, `list`, `get` |
|
|
73
73
|
| Slack | Available | `send` |
|
|
74
74
|
| JIRA | Available | `projects`, `search`, `get`, `comment`, `transitions`, `transition` |
|
|
75
|
+
| RSS | Available | `articles`, `get`, `info` |
|
|
75
76
|
| Linear | Planned | - |
|
|
76
77
|
|
|
77
78
|
## Usage
|
|
@@ -177,6 +178,23 @@ agentio jira transitions PROJ-123
|
|
|
177
178
|
agentio jira transition PROJ-123 <transition-id>
|
|
178
179
|
```
|
|
179
180
|
|
|
181
|
+
### RSS
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# List articles from a blog (feed URL auto-discovered)
|
|
185
|
+
agentio rss articles https://simonwillison.net
|
|
186
|
+
agentio rss articles https://steipete.me --limit 5
|
|
187
|
+
|
|
188
|
+
# Filter by date
|
|
189
|
+
agentio rss articles https://blog.fsck.com --since 2025-01-01
|
|
190
|
+
|
|
191
|
+
# Get feed info (shows discovered feed URL)
|
|
192
|
+
agentio rss info https://kau.sh
|
|
193
|
+
|
|
194
|
+
# Get a specific article
|
|
195
|
+
agentio rss get https://simonwillison.net <article-url>
|
|
196
|
+
```
|
|
197
|
+
|
|
180
198
|
## Multi-Profile Support
|
|
181
199
|
|
|
182
200
|
Each service supports multiple named profiles:
|
|
@@ -192,54 +210,52 @@ agentio gmail list --profile work
|
|
|
192
210
|
|
|
193
211
|
## Claude Code Integration
|
|
194
212
|
|
|
195
|
-
agentio provides plugins for [Claude Code](https://claude.ai/download) with skills for Gmail, Telegram,
|
|
213
|
+
agentio provides plugins for [Claude Code](https://claude.ai/download) with skills for Gmail, Telegram, Google Chat, JIRA, and RSS operations.
|
|
196
214
|
|
|
197
|
-
### Install
|
|
215
|
+
### Install Marketplaces and Plugins
|
|
198
216
|
|
|
199
217
|
```bash
|
|
200
|
-
#
|
|
201
|
-
agentio claude
|
|
218
|
+
# Add a marketplace (auto-detected from URL)
|
|
219
|
+
agentio claude install https://github.com/plosson/agentio
|
|
202
220
|
|
|
203
|
-
#
|
|
204
|
-
agentio claude
|
|
221
|
+
# Install a plugin (auto-detected from name@marketplace format)
|
|
222
|
+
agentio claude install agentio-gmail@agentio
|
|
205
223
|
|
|
206
|
-
# Install
|
|
207
|
-
agentio claude
|
|
208
|
-
|
|
209
|
-
# Install only specific components (skills, commands, hooks, agents)
|
|
210
|
-
agentio claude plugin install plosson/agentio --skills
|
|
211
|
-
agentio claude plugin install plosson/agentio --agents
|
|
212
|
-
|
|
213
|
-
# Force reinstall if already exists
|
|
214
|
-
agentio claude plugin install plosson/agentio -f
|
|
215
|
-
|
|
216
|
-
# Show detailed installation logs
|
|
217
|
-
agentio claude plugin install plosson/agentio --verbose
|
|
224
|
+
# Install all from agentio.json
|
|
225
|
+
agentio claude install
|
|
218
226
|
```
|
|
219
227
|
|
|
220
|
-
Once installed, Claude Code can use the agentio CLI skills to help you manage emails, send Telegram messages, and more.
|
|
221
|
-
|
|
222
228
|
### Manage Plugins
|
|
223
229
|
|
|
224
230
|
```bash
|
|
225
|
-
# List
|
|
226
|
-
agentio claude
|
|
231
|
+
# List marketplaces and plugins from agentio.json
|
|
232
|
+
agentio claude list
|
|
227
233
|
|
|
228
|
-
#
|
|
229
|
-
agentio claude
|
|
234
|
+
# Update marketplaces
|
|
235
|
+
agentio claude update
|
|
236
|
+
agentio claude update https://github.com/plosson/agentio
|
|
237
|
+
|
|
238
|
+
# Remove a marketplace or plugin
|
|
239
|
+
agentio claude remove https://github.com/plosson/agentio
|
|
240
|
+
agentio claude remove agentio-gmail@agentio
|
|
230
241
|
```
|
|
231
242
|
|
|
232
|
-
###
|
|
243
|
+
### agentio.json
|
|
233
244
|
|
|
234
|
-
|
|
245
|
+
Projects can define marketplaces and plugins in an `agentio.json` file:
|
|
235
246
|
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
|
|
247
|
+
```json
|
|
248
|
+
{
|
|
249
|
+
"marketplaces": [
|
|
250
|
+
"https://github.com/plosson/agentio"
|
|
251
|
+
],
|
|
252
|
+
"plugins": [
|
|
253
|
+
"agentio-gmail@agentio",
|
|
254
|
+
"agentio-rss@agentio"
|
|
255
|
+
]
|
|
256
|
+
}
|
|
239
257
|
```
|
|
240
258
|
|
|
241
|
-
Plugins are installed to `.claude/` in the target directory (skills, commands, hooks, and agents subdirectories).
|
|
242
|
-
|
|
243
259
|
## Design
|
|
244
260
|
|
|
245
261
|
agentio is designed for LLM consumption:
|
package/package.json
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { setCredentials, removeCredentials, getCredentials } from '../auth/token-store';
|
|
3
|
+
import { setProfile, removeProfile, listProfiles, getProfile } from '../config/config-manager';
|
|
4
|
+
import { DiscourseClient } from '../services/discourse/client';
|
|
5
|
+
import { CliError, handleError } from '../utils/errors';
|
|
6
|
+
import { prompt, resolveProfileName } from '../utils/stdin';
|
|
7
|
+
import type { DiscourseCredentials } from '../types/discourse';
|
|
8
|
+
import {
|
|
9
|
+
printDiscourseTopicList,
|
|
10
|
+
printDiscourseTopic,
|
|
11
|
+
printDiscourseCategoryList,
|
|
12
|
+
} from '../utils/output';
|
|
13
|
+
|
|
14
|
+
async function getDiscourseClient(
|
|
15
|
+
profileName?: string
|
|
16
|
+
): Promise<{ client: DiscourseClient; profile: string }> {
|
|
17
|
+
const profile = await getProfile('discourse', profileName);
|
|
18
|
+
|
|
19
|
+
if (!profile) {
|
|
20
|
+
throw new CliError(
|
|
21
|
+
'PROFILE_NOT_FOUND',
|
|
22
|
+
profileName
|
|
23
|
+
? `Profile "${profileName}" not found for discourse`
|
|
24
|
+
: 'No default profile configured for discourse',
|
|
25
|
+
'Run: agentio discourse profile add'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const credentials = await getCredentials<DiscourseCredentials>('discourse', profile);
|
|
30
|
+
|
|
31
|
+
if (!credentials) {
|
|
32
|
+
throw new CliError(
|
|
33
|
+
'AUTH_FAILED',
|
|
34
|
+
`No credentials found for discourse profile "${profile}"`,
|
|
35
|
+
`Run: agentio discourse profile add --profile ${profile}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
client: new DiscourseClient(credentials),
|
|
41
|
+
profile,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerDiscourseCommands(program: Command): void {
|
|
46
|
+
const discourse = program.command('discourse').description('Discourse forum operations');
|
|
47
|
+
|
|
48
|
+
// List topics
|
|
49
|
+
discourse
|
|
50
|
+
.command('list')
|
|
51
|
+
.description('List latest topics')
|
|
52
|
+
.option('--profile <name>', 'Profile name')
|
|
53
|
+
.option('--category <slug>', 'Filter by category slug or name')
|
|
54
|
+
.option('--page <number>', 'Page number (0-indexed)', '0')
|
|
55
|
+
.action(async (options) => {
|
|
56
|
+
try {
|
|
57
|
+
const { client } = await getDiscourseClient(options.profile);
|
|
58
|
+
const topics = await client.listTopics({
|
|
59
|
+
category: options.category,
|
|
60
|
+
page: parseInt(options.page, 10),
|
|
61
|
+
});
|
|
62
|
+
printDiscourseTopicList(topics);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
handleError(error);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Get topic detail
|
|
69
|
+
discourse
|
|
70
|
+
.command('get')
|
|
71
|
+
.description('Get a topic with its posts')
|
|
72
|
+
.argument('<topic-id>', 'Topic ID')
|
|
73
|
+
.option('--profile <name>', 'Profile name')
|
|
74
|
+
.action(async (topicId: string, options) => {
|
|
75
|
+
try {
|
|
76
|
+
const id = parseInt(topicId, 10);
|
|
77
|
+
if (isNaN(id)) {
|
|
78
|
+
throw new CliError('INVALID_PARAMS', 'Topic ID must be a number');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { client } = await getDiscourseClient(options.profile);
|
|
82
|
+
const topic = await client.getTopic(id);
|
|
83
|
+
printDiscourseTopic(topic);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
handleError(error);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// List categories
|
|
90
|
+
discourse
|
|
91
|
+
.command('categories')
|
|
92
|
+
.description('List all categories')
|
|
93
|
+
.option('--profile <name>', 'Profile name')
|
|
94
|
+
.action(async (options) => {
|
|
95
|
+
try {
|
|
96
|
+
const { client } = await getDiscourseClient(options.profile);
|
|
97
|
+
const categories = await client.getCategories();
|
|
98
|
+
printDiscourseCategoryList(categories);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
handleError(error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Profile management
|
|
105
|
+
const profile = discourse.command('profile').description('Manage Discourse profiles');
|
|
106
|
+
|
|
107
|
+
profile
|
|
108
|
+
.command('add')
|
|
109
|
+
.description('Add a new Discourse profile')
|
|
110
|
+
.option('--profile <name>', 'Profile name', 'default')
|
|
111
|
+
.action(async (options) => {
|
|
112
|
+
try {
|
|
113
|
+
const profileName = await resolveProfileName('discourse', options.profile);
|
|
114
|
+
|
|
115
|
+
console.error('\n💬 Discourse Setup\n');
|
|
116
|
+
|
|
117
|
+
// Step 1: Get base URL
|
|
118
|
+
console.error('Step 1: Enter your Discourse forum URL');
|
|
119
|
+
console.error(' Example: https://meta.discourse.org or https://forum.example.com\n');
|
|
120
|
+
|
|
121
|
+
const baseUrl = await prompt('? Forum URL: ');
|
|
122
|
+
|
|
123
|
+
if (!baseUrl) {
|
|
124
|
+
throw new CliError('INVALID_PARAMS', 'Forum URL is required');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Normalize URL
|
|
128
|
+
let normalizedUrl = baseUrl.trim();
|
|
129
|
+
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
|
130
|
+
normalizedUrl = `https://${normalizedUrl}`;
|
|
131
|
+
}
|
|
132
|
+
normalizedUrl = normalizedUrl.replace(/\/$/, '');
|
|
133
|
+
|
|
134
|
+
// Step 2: Get API credentials
|
|
135
|
+
console.error('\nStep 2: Create an API key');
|
|
136
|
+
console.error(' 1. Go to your Discourse admin panel');
|
|
137
|
+
console.error(` ${normalizedUrl}/admin/api/keys`);
|
|
138
|
+
console.error(' 2. Click "New API Key"');
|
|
139
|
+
console.error(' 3. Description: "agentio CLI"');
|
|
140
|
+
console.error(' 4. User Level: Choose your user or "All Users" for admin access');
|
|
141
|
+
console.error(' 5. Scope: "Read" (or "Read, Write" if you plan to create topics later)');
|
|
142
|
+
console.error(' 6. Click "Save" and copy the API key\n');
|
|
143
|
+
|
|
144
|
+
const apiKey = await prompt('? API Key: ');
|
|
145
|
+
|
|
146
|
+
if (!apiKey) {
|
|
147
|
+
throw new CliError('INVALID_PARAMS', 'API key is required');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.error('\nStep 3: Enter your Discourse username');
|
|
151
|
+
console.error(' This should match the user associated with the API key\n');
|
|
152
|
+
|
|
153
|
+
const username = await prompt('? Username: ');
|
|
154
|
+
|
|
155
|
+
if (!username) {
|
|
156
|
+
throw new CliError('INVALID_PARAMS', 'Username is required');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate credentials
|
|
160
|
+
console.error('\nValidating credentials...');
|
|
161
|
+
|
|
162
|
+
const credentials: DiscourseCredentials = {
|
|
163
|
+
baseUrl: normalizedUrl,
|
|
164
|
+
apiKey: apiKey.trim(),
|
|
165
|
+
username: username.trim(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const client = new DiscourseClient(credentials);
|
|
169
|
+
try {
|
|
170
|
+
await client.validateCredentials();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof CliError) {
|
|
173
|
+
if (error.code === 'AUTH_FAILED') {
|
|
174
|
+
throw new CliError(
|
|
175
|
+
'AUTH_FAILED',
|
|
176
|
+
'Invalid API key or username. Please check your credentials.',
|
|
177
|
+
'Make sure the API key is active and the username matches'
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (error.code === 'NETWORK_ERROR') {
|
|
181
|
+
throw new CliError(
|
|
182
|
+
'NETWORK_ERROR',
|
|
183
|
+
`Cannot connect to ${normalizedUrl}`,
|
|
184
|
+
'Check the URL and your network connection'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.error(`\n✓ Connected to ${normalizedUrl}`);
|
|
192
|
+
console.error(`✓ Authenticated as ${username}\n`);
|
|
193
|
+
|
|
194
|
+
// Save credentials
|
|
195
|
+
await setProfile('discourse', profileName);
|
|
196
|
+
await setCredentials('discourse', profileName, credentials);
|
|
197
|
+
|
|
198
|
+
console.log(`\n✅ Profile "${profileName}" configured!`);
|
|
199
|
+
console.log(` Test with: agentio discourse list --profile ${profileName}`);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
handleError(error);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
profile
|
|
206
|
+
.command('list')
|
|
207
|
+
.description('List Discourse profiles')
|
|
208
|
+
.action(async () => {
|
|
209
|
+
try {
|
|
210
|
+
const result = await listProfiles('discourse');
|
|
211
|
+
const { profiles, default: defaultProfile } = result[0];
|
|
212
|
+
|
|
213
|
+
if (profiles.length === 0) {
|
|
214
|
+
console.log('No profiles configured');
|
|
215
|
+
} else {
|
|
216
|
+
for (const name of profiles) {
|
|
217
|
+
const marker = name === defaultProfile ? ' (default)' : '';
|
|
218
|
+
const credentials = await getCredentials<DiscourseCredentials>('discourse', name);
|
|
219
|
+
const urlInfo = credentials?.baseUrl ? ` - ${credentials.baseUrl}` : '';
|
|
220
|
+
console.log(`${name}${marker}${urlInfo}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
handleError(error);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
profile
|
|
229
|
+
.command('remove')
|
|
230
|
+
.description('Remove a Discourse profile')
|
|
231
|
+
.requiredOption('--profile <name>', 'Profile name')
|
|
232
|
+
.action(async (options) => {
|
|
233
|
+
try {
|
|
234
|
+
const profileName = options.profile;
|
|
235
|
+
|
|
236
|
+
const removed = await removeProfile('discourse', profileName);
|
|
237
|
+
await removeCredentials('discourse', profileName);
|
|
238
|
+
|
|
239
|
+
if (removed) {
|
|
240
|
+
console.error(`Removed profile "${profileName}"`);
|
|
241
|
+
} else {
|
|
242
|
+
console.error(`Profile "${profileName}" not found`);
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
handleError(error);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
package/src/commands/gmail.ts
CHANGED
|
@@ -185,6 +185,7 @@ Query Syntax Examples:
|
|
|
185
185
|
.option('--body <body>', 'Email body (or pipe via stdin)')
|
|
186
186
|
.option('--html', 'Treat body as HTML')
|
|
187
187
|
.option('--attachment <path>', 'File to attach (repeatable)', (val, acc: string[]) => [...acc, val], [])
|
|
188
|
+
.option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val, acc: string[]) => [...acc, val], [])
|
|
188
189
|
.action(async (options) => {
|
|
189
190
|
try {
|
|
190
191
|
let body = options.body;
|
|
@@ -198,13 +199,32 @@ Query Syntax Examples:
|
|
|
198
199
|
throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
// Process attachments
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
// Process regular attachments
|
|
203
|
+
const regularAttachments: GmailAttachment[] = options.attachment.map((path: string) => ({
|
|
204
|
+
path,
|
|
205
|
+
filename: basename(path),
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
// Process inline images (format: contentId:filepath)
|
|
209
|
+
const inlineAttachments: GmailAttachment[] = options.inline.map((spec: string) => {
|
|
210
|
+
const colonIndex = spec.indexOf(':');
|
|
211
|
+
if (colonIndex === -1) {
|
|
212
|
+
throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
|
|
213
|
+
}
|
|
214
|
+
const contentId = spec.substring(0, colonIndex);
|
|
215
|
+
const path = spec.substring(colonIndex + 1);
|
|
216
|
+
return {
|
|
217
|
+
path,
|
|
218
|
+
filename: basename(path),
|
|
219
|
+
contentId,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Combine attachments
|
|
224
|
+
const attachments: GmailAttachment[] | undefined =
|
|
225
|
+
regularAttachments.length || inlineAttachments.length
|
|
226
|
+
? [...regularAttachments, ...inlineAttachments]
|
|
227
|
+
: undefined;
|
|
208
228
|
|
|
209
229
|
const { client } = await getGmailClient(options.profile);
|
|
210
230
|
const result = await client.send({
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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';
|
|
@@ -33,6 +34,7 @@ registerGChatCommands(program);
|
|
|
33
34
|
registerJiraCommands(program);
|
|
34
35
|
registerSlackCommands(program);
|
|
35
36
|
registerRssCommands(program);
|
|
37
|
+
registerDiscourseCommands(program);
|
|
36
38
|
registerUpdateCommand(program);
|
|
37
39
|
registerConfigCommands(program);
|
|
38
40
|
registerClaudeCommands(program);
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiscourseCredentials,
|
|
3
|
+
DiscourseCategory,
|
|
4
|
+
DiscourseTopic,
|
|
5
|
+
DiscourseTopicDetail,
|
|
6
|
+
DiscoursePost,
|
|
7
|
+
DiscourseListOptions,
|
|
8
|
+
} from '../../types/discourse';
|
|
9
|
+
import { CliError, type ErrorCode } from '../../utils/errors';
|
|
10
|
+
|
|
11
|
+
interface DiscourseApiResponse {
|
|
12
|
+
errors?: string[];
|
|
13
|
+
error_type?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CategoryListResponse extends DiscourseApiResponse {
|
|
17
|
+
category_list: {
|
|
18
|
+
categories: RawCategory[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RawCategory {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
description: string;
|
|
27
|
+
topic_count: number;
|
|
28
|
+
post_count: number;
|
|
29
|
+
color: string;
|
|
30
|
+
parent_category_id?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TopicListResponse extends DiscourseApiResponse {
|
|
34
|
+
topic_list: {
|
|
35
|
+
topics: RawTopic[];
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RawTopic {
|
|
40
|
+
id: number;
|
|
41
|
+
title: string;
|
|
42
|
+
slug: string;
|
|
43
|
+
posts_count: number;
|
|
44
|
+
reply_count: number;
|
|
45
|
+
views: number;
|
|
46
|
+
like_count: number;
|
|
47
|
+
category_id: number;
|
|
48
|
+
created_at: string;
|
|
49
|
+
last_posted_at: string;
|
|
50
|
+
pinned: boolean;
|
|
51
|
+
closed: boolean;
|
|
52
|
+
archived: boolean;
|
|
53
|
+
posters?: Array<{ user_id: number; extras?: string }>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TopicDetailResponse extends DiscourseApiResponse {
|
|
57
|
+
id: number;
|
|
58
|
+
title: string;
|
|
59
|
+
slug: string;
|
|
60
|
+
posts_count: number;
|
|
61
|
+
reply_count: number;
|
|
62
|
+
views: number;
|
|
63
|
+
like_count: number;
|
|
64
|
+
category_id: number;
|
|
65
|
+
created_at: string;
|
|
66
|
+
last_posted_at: string;
|
|
67
|
+
pinned: boolean;
|
|
68
|
+
closed: boolean;
|
|
69
|
+
archived: boolean;
|
|
70
|
+
post_stream: {
|
|
71
|
+
posts: RawPost[];
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface RawPost {
|
|
76
|
+
id: number;
|
|
77
|
+
username: string;
|
|
78
|
+
display_username?: string;
|
|
79
|
+
created_at: string;
|
|
80
|
+
updated_at: string;
|
|
81
|
+
post_number: number;
|
|
82
|
+
raw?: string;
|
|
83
|
+
cooked: string;
|
|
84
|
+
reply_count: number;
|
|
85
|
+
like_count: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class DiscourseClient {
|
|
89
|
+
private baseUrl: string;
|
|
90
|
+
private apiKey: string;
|
|
91
|
+
private username: string;
|
|
92
|
+
private categoryCache: Map<number, string> = new Map();
|
|
93
|
+
|
|
94
|
+
constructor(credentials: DiscourseCredentials) {
|
|
95
|
+
this.baseUrl = credentials.baseUrl.replace(/\/$/, '');
|
|
96
|
+
this.apiKey = credentials.apiKey;
|
|
97
|
+
this.username = credentials.username;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async request<T>(
|
|
101
|
+
method: string,
|
|
102
|
+
path: string,
|
|
103
|
+
body?: Record<string, unknown>
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
const url = `${this.baseUrl}${path}`;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
method,
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
'Api-Key': this.apiKey,
|
|
113
|
+
'Api-Username': this.username,
|
|
114
|
+
},
|
|
115
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const errorCode = this.getErrorCode(response.status);
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
let message = `Discourse API error: ${response.status}`;
|
|
122
|
+
try {
|
|
123
|
+
const data = JSON.parse(text) as DiscourseApiResponse;
|
|
124
|
+
if (data.errors) {
|
|
125
|
+
message = data.errors.join(', ');
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
if (text) message = text;
|
|
129
|
+
}
|
|
130
|
+
throw new CliError(errorCode, message);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (await response.json()) as T;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof CliError) throw error;
|
|
136
|
+
|
|
137
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
138
|
+
throw new CliError('NETWORK_ERROR', `Failed to connect to Discourse: ${message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private getErrorCode(status: number): ErrorCode {
|
|
143
|
+
if (status === 401 || status === 403) return 'AUTH_FAILED';
|
|
144
|
+
if (status === 404) return 'NOT_FOUND';
|
|
145
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
146
|
+
return 'API_ERROR';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async getCategories(): Promise<DiscourseCategory[]> {
|
|
150
|
+
const data = await this.request<CategoryListResponse>('GET', '/categories.json');
|
|
151
|
+
|
|
152
|
+
const categories = data.category_list.categories.map((cat) => this.parseCategory(cat));
|
|
153
|
+
|
|
154
|
+
// Cache category names for topic listings
|
|
155
|
+
for (const cat of categories) {
|
|
156
|
+
this.categoryCache.set(cat.id, cat.name);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return categories;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private parseCategory(raw: RawCategory): DiscourseCategory {
|
|
163
|
+
return {
|
|
164
|
+
id: raw.id,
|
|
165
|
+
name: raw.name,
|
|
166
|
+
slug: raw.slug,
|
|
167
|
+
description: raw.description || '',
|
|
168
|
+
topicCount: raw.topic_count,
|
|
169
|
+
postCount: raw.post_count,
|
|
170
|
+
color: raw.color,
|
|
171
|
+
parentCategoryId: raw.parent_category_id,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async listTopics(options?: DiscourseListOptions): Promise<DiscourseTopic[]> {
|
|
176
|
+
let path: string;
|
|
177
|
+
|
|
178
|
+
if (options?.category) {
|
|
179
|
+
// Need to find category by slug
|
|
180
|
+
const categories = await this.getCategories();
|
|
181
|
+
const category = categories.find(
|
|
182
|
+
(c) => c.slug === options.category || c.name.toLowerCase() === options.category?.toLowerCase()
|
|
183
|
+
);
|
|
184
|
+
if (!category) {
|
|
185
|
+
throw new CliError('NOT_FOUND', `Category "${options.category}" not found`);
|
|
186
|
+
}
|
|
187
|
+
path = `/c/${category.slug}/${category.id}/l/latest.json`;
|
|
188
|
+
} else {
|
|
189
|
+
path = '/latest.json';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options?.page && options.page > 0) {
|
|
193
|
+
path += `?page=${options.page}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const data = await this.request<TopicListResponse>('GET', path);
|
|
197
|
+
|
|
198
|
+
// Ensure category cache is populated
|
|
199
|
+
if (this.categoryCache.size === 0) {
|
|
200
|
+
await this.getCategories();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return data.topic_list.topics.map((topic) => this.parseTopic(topic));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private parseTopic(raw: RawTopic): DiscourseTopic {
|
|
207
|
+
return {
|
|
208
|
+
id: raw.id,
|
|
209
|
+
title: raw.title,
|
|
210
|
+
slug: raw.slug,
|
|
211
|
+
postsCount: raw.posts_count,
|
|
212
|
+
replyCount: raw.reply_count,
|
|
213
|
+
views: raw.views,
|
|
214
|
+
likeCount: raw.like_count,
|
|
215
|
+
categoryId: raw.category_id,
|
|
216
|
+
categoryName: this.categoryCache.get(raw.category_id),
|
|
217
|
+
createdAt: raw.created_at,
|
|
218
|
+
lastPostedAt: raw.last_posted_at,
|
|
219
|
+
pinned: raw.pinned,
|
|
220
|
+
closed: raw.closed,
|
|
221
|
+
archived: raw.archived,
|
|
222
|
+
posters: raw.posters?.map((p) => ({ userId: p.user_id })),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async getTopic(topicId: number): Promise<DiscourseTopicDetail> {
|
|
227
|
+
const data = await this.request<TopicDetailResponse>('GET', `/t/${topicId}.json`);
|
|
228
|
+
|
|
229
|
+
// Ensure category cache is populated
|
|
230
|
+
if (this.categoryCache.size === 0) {
|
|
231
|
+
await this.getCategories();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
id: data.id,
|
|
236
|
+
title: data.title,
|
|
237
|
+
slug: data.slug,
|
|
238
|
+
postsCount: data.posts_count,
|
|
239
|
+
replyCount: data.reply_count,
|
|
240
|
+
views: data.views,
|
|
241
|
+
likeCount: data.like_count,
|
|
242
|
+
categoryId: data.category_id,
|
|
243
|
+
categoryName: this.categoryCache.get(data.category_id),
|
|
244
|
+
createdAt: data.created_at,
|
|
245
|
+
lastPostedAt: data.last_posted_at,
|
|
246
|
+
pinned: data.pinned,
|
|
247
|
+
closed: data.closed,
|
|
248
|
+
archived: data.archived,
|
|
249
|
+
posts: data.post_stream.posts.map((p) => this.parsePost(p)),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private parsePost(raw: RawPost): DiscoursePost {
|
|
254
|
+
return {
|
|
255
|
+
id: raw.id,
|
|
256
|
+
username: raw.username,
|
|
257
|
+
displayName: raw.display_username,
|
|
258
|
+
createdAt: raw.created_at,
|
|
259
|
+
updatedAt: raw.updated_at,
|
|
260
|
+
postNumber: raw.post_number,
|
|
261
|
+
raw: raw.raw,
|
|
262
|
+
cooked: raw.cooked,
|
|
263
|
+
replyCount: raw.reply_count,
|
|
264
|
+
likeCount: raw.like_count,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async validateCredentials(): Promise<{ username: string }> {
|
|
269
|
+
// Try to fetch categories as a validation check
|
|
270
|
+
await this.getCategories();
|
|
271
|
+
return { username: this.username };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -308,54 +308,140 @@ export class GmailClient {
|
|
|
308
308
|
attachments: GmailAttachment[];
|
|
309
309
|
}): Promise<string> {
|
|
310
310
|
const { from, to, cc, bcc, subject, body, isHtml, attachments } = options;
|
|
311
|
-
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
|
312
|
-
|
|
313
|
-
const headers = [
|
|
314
|
-
`From: ${from}`,
|
|
315
|
-
`To: ${to.join(', ')}`,
|
|
316
|
-
cc?.length ? `Cc: ${cc.join(', ')}` : null,
|
|
317
|
-
bcc?.length ? `Bcc: ${bcc.join(', ')}` : null,
|
|
318
|
-
`Subject: ${subject}`,
|
|
319
|
-
'MIME-Version: 1.0',
|
|
320
|
-
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
321
|
-
'',
|
|
322
|
-
`--${boundary}`,
|
|
323
|
-
`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
|
|
324
|
-
'',
|
|
325
|
-
body,
|
|
326
|
-
].filter((line): line is string => line !== null);
|
|
327
|
-
|
|
328
|
-
// Add attachments
|
|
329
|
-
for (const attachment of attachments) {
|
|
330
|
-
try {
|
|
331
|
-
const file = Bun.file(attachment.path);
|
|
332
|
-
const exists = await file.exists();
|
|
333
|
-
if (!exists) {
|
|
334
|
-
throw new CliError('NOT_FOUND', `Attachment not found: ${attachment.path}`);
|
|
335
|
-
}
|
|
336
311
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
312
|
+
// Separate inline and regular attachments
|
|
313
|
+
const inlineAttachments = attachments.filter(a => a.contentId);
|
|
314
|
+
const regularAttachments = attachments.filter(a => !a.contentId);
|
|
315
|
+
|
|
316
|
+
const hasInline = inlineAttachments.length > 0;
|
|
317
|
+
const hasRegular = regularAttachments.length > 0;
|
|
318
|
+
|
|
319
|
+
// Generate boundaries
|
|
320
|
+
const mixedBoundary = `----=_Mixed_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
|
321
|
+
const relatedBoundary = `----=_Related_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
|
322
|
+
|
|
323
|
+
const lines: string[] = [];
|
|
324
|
+
|
|
325
|
+
// Email headers
|
|
326
|
+
lines.push(`From: ${from}`);
|
|
327
|
+
lines.push(`To: ${to.join(', ')}`);
|
|
328
|
+
if (cc?.length) lines.push(`Cc: ${cc.join(', ')}`);
|
|
329
|
+
if (bcc?.length) lines.push(`Bcc: ${bcc.join(', ')}`);
|
|
330
|
+
lines.push(`Subject: ${subject}`);
|
|
331
|
+
lines.push('MIME-Version: 1.0');
|
|
332
|
+
|
|
333
|
+
if (hasRegular && hasInline) {
|
|
334
|
+
// Both: multipart/mixed containing multipart/related + regular attachments
|
|
335
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
|
|
336
|
+
lines.push('');
|
|
337
|
+
lines.push(`--${mixedBoundary}`);
|
|
338
|
+
lines.push(`Content-Type: multipart/related; boundary="${relatedBoundary}"`);
|
|
339
|
+
lines.push('');
|
|
340
|
+
|
|
341
|
+
// HTML body
|
|
342
|
+
lines.push(`--${relatedBoundary}`);
|
|
343
|
+
lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
|
|
344
|
+
lines.push('');
|
|
345
|
+
lines.push(body);
|
|
346
|
+
|
|
347
|
+
// Inline images
|
|
348
|
+
for (const attachment of inlineAttachments) {
|
|
349
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
350
|
+
lines.push(`--${relatedBoundary}`);
|
|
351
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
352
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
353
|
+
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
354
|
+
lines.push(`Content-Disposition: inline; filename="${encoded.filename}"`);
|
|
355
|
+
lines.push('');
|
|
356
|
+
lines.push(encoded.base64);
|
|
357
|
+
}
|
|
358
|
+
lines.push(`--${relatedBoundary}--`);
|
|
359
|
+
|
|
360
|
+
// Regular attachments
|
|
361
|
+
for (const attachment of regularAttachments) {
|
|
362
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
363
|
+
lines.push(`--${mixedBoundary}`);
|
|
364
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
365
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
366
|
+
lines.push(`Content-Disposition: attachment; filename="${encoded.filename}"`);
|
|
367
|
+
lines.push('');
|
|
368
|
+
lines.push(encoded.base64);
|
|
369
|
+
}
|
|
370
|
+
lines.push(`--${mixedBoundary}--`);
|
|
371
|
+
|
|
372
|
+
} else if (hasInline) {
|
|
373
|
+
// Only inline: multipart/related
|
|
374
|
+
lines.push(`Content-Type: multipart/related; boundary="${relatedBoundary}"`);
|
|
375
|
+
lines.push('');
|
|
376
|
+
|
|
377
|
+
// HTML body
|
|
378
|
+
lines.push(`--${relatedBoundary}`);
|
|
379
|
+
lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
|
|
380
|
+
lines.push('');
|
|
381
|
+
lines.push(body);
|
|
382
|
+
|
|
383
|
+
// Inline images
|
|
384
|
+
for (const attachment of inlineAttachments) {
|
|
385
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
386
|
+
lines.push(`--${relatedBoundary}`);
|
|
387
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
388
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
389
|
+
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
390
|
+
lines.push(`Content-Disposition: inline; filename="${encoded.filename}"`);
|
|
391
|
+
lines.push('');
|
|
392
|
+
lines.push(encoded.base64);
|
|
393
|
+
}
|
|
394
|
+
lines.push(`--${relatedBoundary}--`);
|
|
395
|
+
|
|
396
|
+
} else {
|
|
397
|
+
// Only regular: multipart/mixed (original behavior)
|
|
398
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
|
|
399
|
+
lines.push('');
|
|
400
|
+
lines.push(`--${mixedBoundary}`);
|
|
401
|
+
lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
|
|
402
|
+
lines.push('');
|
|
403
|
+
lines.push(body);
|
|
404
|
+
|
|
405
|
+
for (const attachment of regularAttachments) {
|
|
406
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
407
|
+
lines.push(`--${mixedBoundary}`);
|
|
408
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
409
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
410
|
+
lines.push(`Content-Disposition: attachment; filename="${encoded.filename}"`);
|
|
411
|
+
lines.push('');
|
|
412
|
+
lines.push(encoded.base64);
|
|
353
413
|
}
|
|
414
|
+
lines.push(`--${mixedBoundary}--`);
|
|
354
415
|
}
|
|
355
416
|
|
|
356
|
-
|
|
417
|
+
return lines.join('\r\n');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async encodeAttachment(attachment: GmailAttachment): Promise<{
|
|
421
|
+
filename: string;
|
|
422
|
+
mimeType: string;
|
|
423
|
+
base64: string;
|
|
424
|
+
}> {
|
|
425
|
+
try {
|
|
426
|
+
const file = Bun.file(attachment.path);
|
|
427
|
+
const exists = await file.exists();
|
|
428
|
+
if (!exists) {
|
|
429
|
+
throw new CliError('NOT_FOUND', `Attachment not found: ${attachment.path}`);
|
|
430
|
+
}
|
|
357
431
|
|
|
358
|
-
|
|
432
|
+
const content = await file.arrayBuffer();
|
|
433
|
+
const filename = attachment.filename || basename(attachment.path);
|
|
434
|
+
const mimeType = attachment.mimeType || getMimeType(filename);
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
filename,
|
|
438
|
+
mimeType,
|
|
439
|
+
base64: Buffer.from(content).toString('base64'),
|
|
440
|
+
};
|
|
441
|
+
} catch (error: any) {
|
|
442
|
+
if (error instanceof CliError) throw error;
|
|
443
|
+
throw new CliError('API_ERROR', `Failed to read attachment ${attachment.path}: ${error.message}`);
|
|
444
|
+
}
|
|
359
445
|
}
|
|
360
446
|
|
|
361
447
|
async reply(options: GmailReplyOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
|
package/src/types/config.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface Config {
|
|
|
5
5
|
jira?: string[];
|
|
6
6
|
slack?: string[];
|
|
7
7
|
telegram?: string[];
|
|
8
|
+
discourse?: string[];
|
|
8
9
|
};
|
|
9
10
|
defaults: {
|
|
10
11
|
gmail?: string;
|
|
@@ -12,7 +13,8 @@ export interface Config {
|
|
|
12
13
|
jira?: string;
|
|
13
14
|
slack?: string;
|
|
14
15
|
telegram?: string;
|
|
16
|
+
discourse?: string;
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
export type ServiceName = 'gmail' | 'gchat' | 'jira' | 'slack' | 'telegram';
|
|
20
|
+
export type ServiceName = 'gmail' | 'gchat' | 'jira' | 'slack' | 'telegram' | 'discourse';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface DiscourseCredentials {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
apiKey: string;
|
|
4
|
+
username: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface DiscourseCategory {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
description: string;
|
|
12
|
+
topicCount: number;
|
|
13
|
+
postCount: number;
|
|
14
|
+
color: string;
|
|
15
|
+
parentCategoryId?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DiscoursePoster {
|
|
19
|
+
userId: number;
|
|
20
|
+
username?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DiscourseTopic {
|
|
24
|
+
id: number;
|
|
25
|
+
title: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
postsCount: number;
|
|
28
|
+
replyCount: number;
|
|
29
|
+
views: number;
|
|
30
|
+
likeCount: number;
|
|
31
|
+
categoryId: number;
|
|
32
|
+
categoryName?: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
lastPostedAt: string;
|
|
35
|
+
pinned: boolean;
|
|
36
|
+
closed: boolean;
|
|
37
|
+
archived: boolean;
|
|
38
|
+
posters?: DiscoursePoster[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DiscoursePost {
|
|
42
|
+
id: number;
|
|
43
|
+
username: string;
|
|
44
|
+
displayName?: string;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
postNumber: number;
|
|
48
|
+
raw?: string;
|
|
49
|
+
cooked: string;
|
|
50
|
+
replyCount: number;
|
|
51
|
+
likeCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DiscourseTopicDetail extends DiscourseTopic {
|
|
55
|
+
posts: DiscoursePost[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DiscourseListOptions {
|
|
59
|
+
category?: string;
|
|
60
|
+
page?: number;
|
|
61
|
+
order?: 'default' | 'created' | 'activity' | 'views' | 'posts' | 'likes';
|
|
62
|
+
}
|
package/src/types/gmail.ts
CHANGED
package/src/utils/output.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { GChatMessage } from '../types/gchat';
|
|
|
3
3
|
import type { JiraProject, JiraIssue, JiraTransition, JiraCommentResult, JiraTransitionResult } from '../types/jira';
|
|
4
4
|
import type { SlackSendResult } from '../types/slack';
|
|
5
5
|
import type { RssFeed, RssArticle } from '../types/rss';
|
|
6
|
+
import type { DiscourseCategory, DiscourseTopic, DiscourseTopicDetail } from '../types/discourse';
|
|
6
7
|
|
|
7
8
|
function formatBytes(bytes: number): string {
|
|
8
9
|
if (bytes === 0) return '0 B';
|
|
@@ -278,3 +279,78 @@ export function printRssFeedInfo(feed: RssFeed & { feedUrl: string }): void {
|
|
|
278
279
|
if (feed.lastBuildDate) console.log(`Last Updated: ${feed.lastBuildDate}`);
|
|
279
280
|
console.log(`Articles: ${feed.items.length}`);
|
|
280
281
|
}
|
|
282
|
+
|
|
283
|
+
// Discourse specific formatters
|
|
284
|
+
export function printDiscourseCategoryList(categories: DiscourseCategory[]): void {
|
|
285
|
+
if (categories.length === 0) {
|
|
286
|
+
console.log('No categories found');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
console.log(`Categories (${categories.length})\n`);
|
|
291
|
+
|
|
292
|
+
for (const cat of categories) {
|
|
293
|
+
const parentInfo = cat.parentCategoryId ? ' (subcategory)' : '';
|
|
294
|
+
console.log(`[${cat.id}] ${cat.name}${parentInfo}`);
|
|
295
|
+
console.log(` Slug: ${cat.slug}`);
|
|
296
|
+
console.log(` Topics: ${cat.topicCount} | Posts: ${cat.postCount}`);
|
|
297
|
+
if (cat.description) {
|
|
298
|
+
const desc = cat.description.replace(/<[^>]*>/g, '').substring(0, 100);
|
|
299
|
+
if (desc) console.log(` > ${desc}${cat.description.length > 100 ? '...' : ''}`);
|
|
300
|
+
}
|
|
301
|
+
console.log('');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function printDiscourseTopicList(topics: DiscourseTopic[]): void {
|
|
306
|
+
if (topics.length === 0) {
|
|
307
|
+
console.log('No topics found');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
console.log(`Topics (${topics.length})\n`);
|
|
312
|
+
|
|
313
|
+
for (let i = 0; i < topics.length; i++) {
|
|
314
|
+
const topic = topics[i];
|
|
315
|
+
const flags: string[] = [];
|
|
316
|
+
if (topic.pinned) flags.push('pinned');
|
|
317
|
+
if (topic.closed) flags.push('closed');
|
|
318
|
+
if (topic.archived) flags.push('archived');
|
|
319
|
+
const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
|
|
320
|
+
|
|
321
|
+
console.log(`[${i + 1}] ${topic.id} | ${topic.title}${flagStr}`);
|
|
322
|
+
if (topic.categoryName) console.log(` Category: ${topic.categoryName}`);
|
|
323
|
+
console.log(` Posts: ${topic.postsCount} | Replies: ${topic.replyCount} | Views: ${topic.views} | Likes: ${topic.likeCount}`);
|
|
324
|
+
console.log(` Created: ${topic.createdAt}`);
|
|
325
|
+
if (topic.lastPostedAt !== topic.createdAt) {
|
|
326
|
+
console.log(` Last Post: ${topic.lastPostedAt}`);
|
|
327
|
+
}
|
|
328
|
+
console.log('');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function printDiscourseTopic(topic: DiscourseTopicDetail): void {
|
|
333
|
+
const flags: string[] = [];
|
|
334
|
+
if (topic.pinned) flags.push('pinned');
|
|
335
|
+
if (topic.closed) flags.push('closed');
|
|
336
|
+
if (topic.archived) flags.push('archived');
|
|
337
|
+
const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
|
|
338
|
+
|
|
339
|
+
console.log(`ID: ${topic.id}`);
|
|
340
|
+
console.log(`Title: ${topic.title}${flagStr}`);
|
|
341
|
+
console.log(`Slug: ${topic.slug}`);
|
|
342
|
+
if (topic.categoryName) console.log(`Category: ${topic.categoryName}`);
|
|
343
|
+
console.log(`Posts: ${topic.postsCount} | Replies: ${topic.replyCount} | Views: ${topic.views} | Likes: ${topic.likeCount}`);
|
|
344
|
+
console.log(`Created: ${topic.createdAt}`);
|
|
345
|
+
console.log(`Last Post: ${topic.lastPostedAt}`);
|
|
346
|
+
console.log('---');
|
|
347
|
+
|
|
348
|
+
for (const post of topic.posts) {
|
|
349
|
+
const author = post.displayName || post.username;
|
|
350
|
+
console.log(`\n[Post #${post.postNumber}] by ${author} (${post.createdAt})`);
|
|
351
|
+
console.log(`Likes: ${post.likeCount} | Replies: ${post.replyCount}`);
|
|
352
|
+
// Strip HTML from cooked content for display
|
|
353
|
+
const content = post.cooked.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
354
|
+
console.log(content);
|
|
355
|
+
}
|
|
356
|
+
}
|