Skip to content

Commit

Permalink
feat: api version 2
Browse files Browse the repository at this point in the history
  • Loading branch information
Fyko committed Feb 15, 2021
1 parent c584519 commit d2970ab
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 25 deletions.
1 change: 0 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ on:
push:

jobs:

docker:
name: Docker
runs-on: ubuntu-latest
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ name: Test

on:
push:
branches: [ csharp ]
branches: [csharp]
pull_request:
branches: [ csharp ]
branches: [csharp]

jobs:
build:

runs-on: ubuntu-latest

steps:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,17 @@
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Domain.Discord.Models;
using Newtonsoft.Json;
namespace ExportAPI.Controllers
{
[ApiController]
[Route("v1/export")]
public class ExportController : ControllerBase
public class ExportControllerv1 : ControllerBase
{
private readonly ILogger<ExportController> _logger;

private readonly HttpClient _httpclient = new HttpClient();

public ExportController(ILogger<ExportController> logger)
public ExportControllerv1(ILogger<ExportController> logger)
{
_logger = logger;

Expand All @@ -43,7 +42,7 @@ internal static string GetPath(string channelId)
}

[HttpPost]
public async Task<Stream> Post(ExportOptions options)
public async Task<Stream> Post(ExportOptionsv1 options)
{
var parsed = Snowflake.TryParse(options.ChannelId);
var channelId = parsed ?? Snowflake.Zero;
Expand All @@ -57,7 +56,6 @@ public async Task<Stream> Post(ExportOptions options)
}
catch (DiscordChatExporterException e)
{
_logger.LogError($"{e}");
var isUnauthorized = e.Message.Contains("Authentication");
var content = isUnauthorized ? "Invalid Discord token provided." : "Please provide a valid channel";

Expand Down Expand Up @@ -104,7 +102,8 @@ public async Task<Stream> Post(ExportOptions options)
return stream;
}

async internal void deleteFile(string path) {
async internal void deleteFile(string path)
{
await Task.Delay(TimeSpan.FromSeconds(30));
System.IO.File.Delete(path);
_logger.LogInformation($"Deleted {path}");
Expand All @@ -121,7 +120,7 @@ internal Stream GenerateStreamFromString(string s)
}
}

public class ExportOptions
public class ExportOptionsv1
{
public string Token { get; set; }
public string ChannelId { get; set; }
Expand Down
156 changes: 156 additions & 0 deletions ExportAPI/Controllers/v2/ExportController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Domain.Discord.Models;
namespace ExportAPI.Controllers
{
[ApiController]
[Route("v2/export")]
public class ExportController : ControllerBase
{
private readonly ILogger<ExportController> _logger;

private readonly HttpClient _httpclient = new HttpClient();

public ExportController(ILogger<ExportController> logger)
{
_logger = logger;

if (!Directory.Exists("/tmp/exports"))
{
Directory.CreateDirectory("/tmp/exports");
}

var nowhex = DateTime.Now.Ticks.ToString("X2");
if (!Directory.Exists($"/tmp/exports/{nowhex}"))
{
Directory.CreateDirectory($"/tmp/exports/{nowhex}");
}
}

internal static string GetPath(string channelId, ExportFormat exportType)
{
var nowhex = DateTime.Now.Ticks.ToString("X2");
return $"/tmp/exports/{nowhex}/{channelId}.{exportType.GetFileExtension()}";
}

[HttpPost]
public async Task<Stream> Post(ExportOptions options)
{
Stream SendJsonError(string error, int status)
{
Response.ContentType = "application/json";
Response.StatusCode = status;
return GenerateStreamFromString("{ \"error\": \"" + error + "\" }");
}

if (!Enum.IsDefined(typeof(ExportFormat), options.export_format))
{
return SendJsonError($"An export format with the id '{options.export_format}' was not found.", 400);
}

var parsed = Snowflake.TryParse(options.channel_id);
var channelId = parsed ?? Snowflake.Zero;

var token = new AuthToken(AuthTokenType.Bot, options.token);
var client = new DiscordClient(token);
Channel channel;
try
{
channel = await client.GetChannelAsync(channelId);
}
catch (DiscordChatExporterException e)
{
if (e.Message.Contains("Authentication")) return SendJsonError("An invalid Discord token was provided.", 401);
if (e.Message.Contains("Requested resource does not exist")) return SendJsonError("A channel with the provided ID was not found.", 404);
return SendJsonError($"An unknown error occurred: {e.Message}", 500);
}

var guild = await client.GetGuildAsync(channel.GuildId);

using var req = new HttpRequestMessage(HttpMethod.Get, new Uri("https://discord.com/api/v8/users/@me"));
req.Headers.Authorization = token.GetAuthorizationHeader();
var res = await _httpclient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
var text = await res.Content.ReadAsStringAsync();
var me = DiscordChatExporter.Domain.Discord.Models.User.Parse(JsonDocument.Parse(text).RootElement.Clone());

var path = GetPath(channel.Id.ToString(), options.export_format);
_logger.LogInformation($"[{me.FullName} ({me.Id})] Exporting #{channel.Name} ({channel.Id}) within {guild.Name} ({guild.Id}) to {path}");
var request = new ExportRequest(
guild,
channel,
path,
options.export_format,
Snowflake.TryParse(options.after),
Snowflake.TryParse(options.before),
null,
false,
false,
options.date_format
);

var exporter = new ChannelExporter(client);

_logger.LogInformation("Starting export");
await exporter.ExportChannelAsync(request);
_logger.LogInformation("Finished exporting");
var stream = new FileStream(path, FileMode.Open);

var ext = options.export_format.GetFileExtension();
if (ext == "txt") ext = "plain";
Response.ContentType = $"text/{ext}; charset=UTF-8";
Response.StatusCode = 200;

deleteFile(path);

return stream;
}

async internal void deleteFile(string path)
{
await Task.Delay(TimeSpan.FromSeconds(30));
var exists = System.IO.File.Exists(path);
if (exists)
{
try
{
System.IO.File.Delete(path);
_logger.LogInformation($"Deleted {path}");
}
catch { }
}

}

internal Stream GenerateStreamFromString(string s)
{
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(s);
writer.Flush();
stream.Position = 0;
return stream;
}
}

public class ExportOptions
{
public string token { get; set; }
public string channel_id { get; set; }

public string after { get; set; }

public string before { get; set; }

public ExportFormat export_format { get; set; } = ExportFormat.HtmlDark;

public string date_format { get; set; } = "dd-MMM-yy hh:mm tt";
}
}
3 changes: 3 additions & 0 deletions ExportAPI/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public Startup(IConfiguration configuration)
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
// services.AddControllers().AddJsonOptions(jo => {
// jo.JsonSerializerOptions.PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance;
// });
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
Expand Down
17 changes: 17 additions & 0 deletions ExportAPI/util/SnakeCaseNamingPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Text.Json;
using Newtonsoft.Json.Serialization;

public class SnakeCaseNamingPolicy : JsonNamingPolicy
{
private readonly SnakeCaseNamingStrategy _newtonsoftSnakeCaseNamingStrategy
= new SnakeCaseNamingStrategy();

public static SnakeCaseNamingPolicy Instance { get; } = new SnakeCaseNamingPolicy();

public override string ConvertName(string name)
{
/* A conversion to snake case implementation goes here. */

return _newtonsoftSnakeCaseNamingStrategy.GetPropertyName(name, false);
}
}
97 changes: 84 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# 📥 Discord Channel Exporter API
![Test](https://github.com/Fyko/export-api/workflows/Test/badge.svg)
![Docker Pulls](https://img.shields.io/docker/pulls/fyko/export-api)
An API to create HTML exports of Discord text-channels

## Preface
This API utilized [`Tyrrrz/DiscordChatExporter`](https://github.com/Tyrrrz/DiscordChatExporter) for exporting channels.

## Usage
### Docker Image
The Export API can be found on the [Docker Hub](https://hub.docker.com/) at [`fyko/export-api`](https://hub.docker.com/r/fyko/export-api).
The Export API can be found on the [Docker Hub](https://hub.docker.com/) at [`fyko/export-api`](https://hub.docker.com/r/fyko/export-api).

#### Using the Docker Image
```sh
Expand All @@ -23,20 +25,88 @@ services:
- "yourport"
```

### Performing Requests
`POST` to `/v1/export` with the JSON body:
```json
{
"token": "discord bot token",
"channelId": "guild channel id"
## Performing Requests: Version 2

__Export Formats__
| **Type** | **ID** | **Description** | **File Extension** |
|-----------|--------|-----------------------------------------|--------------------|
| PlainText | 0 | Export to a plaintext file | txt |
| HtmlDark | 1 | Export to an HTML file in dark mode | html |
| HtmlLight | 2 | Export to an HTML file in light mode | html |
| CSV | 3 | Export to a comma separated values file | csv |
| JSON | 4 | Export to a JSON file | json |

### `POST` `/v2/export`
Exports a channel. On success, it returns a file stream.

__JSON Body__
| **Field** | **Type** | **Description** |
|---------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| token | string | The bot token for performing requests |
| channel_id | string | The id of the channel to export |
| export_format | ?ExportFormat | The format to export the channel as, defaults to `HtmlDark` |
| date_format | string | The [date format](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings) for dates in exported files, defaults to `dd-MMM-yy hh:mm tt` |
| after | ?string | Only include messages sent after this date |
| before | ?string | Only include messages sent before this date |

### Examples
#### Typescript:
```ts
import fetch from 'node-fetch';

async function exportChannel(channel_id: string, token: string): Promise<Buffer> {
const response = await fetch('http://localhost:8008/v2/export', {
method: 'POST',
body: JSON.stringify({ channel_id, token }),
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
return response.buffer();
}
throw Error('Channel export failed!');
}
```
#### Response Codes
**Status**|**Description**
-----:|-----
200|Success - exported channel sent as text/html
401|Unauthorized - bad Discord bot token
409|Conflict - unknown channel
#### Rust
```rust
// reqwest = { version = "0.10", features = ["json"] }
use reqwest::Client;
use std::collections::HashMap;
use std::io::copy;
use std::fs::File;

async fn export_channel(channelId: &str, token: &str) -> Result<File, reqwest::Error> {
let client = Client::new();
let mut map = HashMap::new();
map.insert("channel_id", "channel id");
map.insert("token", "discord token");

let file = client.post("http://localhost:8008/v2/export").json(&map).await?.text().await?;

let dest = File::create("myexport.html")?;
copy(&mut file.as_bytes(), &mut dest)?;

Ok(dest)
}
```


## Performing Requets: Version 1

### `POST` `/v1/export`
__JSON Body__
| **Field** | **Type** | **Description** |
|-----------|----------|---------------------------------------|
| token | string | The bot token for performing requests |
| channelId | string | The id of the channel to export |

__Response Codes__
| **Status** | **Description** |
|------------|----------------------------------------------|
| 200 | Success - exported channel sent as text/html |
| 401 | Unauthorized - bad Discord bot token |
| 409 | Conflict - unknown channel |

### Examples
#### Typescript:
Expand Down Expand Up @@ -79,3 +149,4 @@ async fn export_channel(channelId: &str, token: &str) -> Result<File, reqwest::E
Ok(dest)
}
```

0 comments on commit d2970ab

Please sign in to comment.