├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── build.zig ├── src ├── channel.zig ├── discord.zig ├── guild.zig └── main.zig ├── zig.mod ├── zigmod.lock └── zigmod.sum /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.zigmod 2 | /deps.zig 3 | /zig-* 4 | /*.txt 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU Affero General Public License v3.0 2 | 3 | Copyright (c) 2021 Meghan Denny 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU Affero General Public License for more details. 14 | 15 | You should have received a copy of the GNU Affero General Public License 16 | along with this program. If not, see . 17 | 18 | https://www.gnu.org/licenses/agpl-3.0.txt 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord-archiver 2 | ![loc](https://sloc.xyz/github/nektro/discord-archiver) 3 | [![license](https://img.shields.io/github/license/nektro/discord-archiver.svg)](https://github.com/nektro/discord-archiver/blob/master/LICENSE) 4 | [![discord](https://img.shields.io/discord/551971034593755159.svg?logo=discord)](https://discord.gg/P6Y4zQC) 5 | 6 | 7 | 8 | 9 | An archiver for Discord. Written in Zig. 10 | 11 | ## Usage 12 | 1. Channels 13 | ``` 14 | $ ./discord-archiver channel 15 | ``` 16 | 17 | 2. Guilds 18 | ``` 19 | $ ./discord-archiver guild 20 | ``` 21 | 22 | ## Zig 23 | - https://ziglang.org/ 24 | - https://github.com/ziglang/zig 25 | - https://github.com/ziglang/zig/wiki/Community 26 | 27 | ## Building 28 | ``` 29 | $ zigmod fetch 30 | $ zig build 31 | ``` 32 | 33 | ## Built With 34 | - Zig Master & [Zigmod Package Manager](https://github.com/nektro/zigmod) 35 | - https://github.com/nektro/zig-ansi 36 | - https://github.com/truemedian/zfetch 37 | 38 | ## License 39 | AGPL-3.0 40 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const deps = @import("./deps.zig"); 3 | 4 | pub fn build(b: *std.build.Builder) void { 5 | const target = b.standardTargetOptions(.{}); 6 | 7 | const mode = b.standardReleaseOptions(); 8 | 9 | const exe = b.addExecutable("discord-archiver", "src/main.zig"); 10 | exe.setTarget(target); 11 | exe.setBuildMode(mode); 12 | deps.addAllTo(exe); 13 | exe.install(); 14 | 15 | const run_cmd = exe.run(); 16 | run_cmd.step.dependOn(b.getInstallStep()); 17 | if (b.args) |args| { 18 | run_cmd.addArgs(args); 19 | } 20 | 21 | const run_step = b.step("run", "Run the app"); 22 | run_step.dependOn(&run_cmd.step); 23 | } 24 | -------------------------------------------------------------------------------- /src/channel.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const json = @import("json"); 3 | const range = @import("range").range; 4 | 5 | const discord = @import("./discord.zig"); 6 | 7 | pub fn execute(alloc: *std.mem.Allocator, args: [][]u8) !void { 8 | const bot_token = args[0]; 9 | 10 | for (range(args.len - 1)) |_, i| { 11 | try do(alloc, bot_token, args[i + 1]); 12 | } 13 | } 14 | 15 | pub fn do(alloc: *std.mem.Allocator, bot_token: []const u8, channel_id: []const u8) !void { 16 | const channel = try discord.get_channel(alloc, bot_token, channel_id); 17 | if (channel.?.get("message")) |_| { 18 | std.log.warn("{}", .{channel}); 19 | return; 20 | } 21 | const name = channel.?.get("name").?.String; 22 | std.log.info("now backing up channel: {s} {s}", .{ channel_id, name }); 23 | 24 | const save_path = try std.mem.join(alloc, "", &.{ channel_id, "_", name, ".txt" }); 25 | 26 | const f = try std.fs.cwd().createFile(save_path, .{ .read = true, .truncate = false }); 27 | defer f.close(); 28 | const r = f.reader(); 29 | const w = f.writer(); 30 | 31 | const message_list = &std.ArrayList(json.Value).init(alloc); 32 | defer message_list.deinit(); 33 | 34 | if ((try f.getEndPos()) == 0) { 35 | // full sync 36 | var next_flake: []const u8 = ""; 37 | while (try discord.get_channel_messages(alloc, bot_token, channel_id, .before, next_flake)) |messages| { 38 | if (messages.len == 0) break; 39 | std.log.info("found {} messages starting at {s}, {} total so far", .{ messages.len, messages[0].get("id").?.String, message_list.items.len + messages.len }); 40 | try message_list.appendSlice(messages); 41 | if (messages.len < 50) break; 42 | std.time.sleep(std.time.ns_per_s); 43 | next_flake = messages[messages.len - 1].get("id").?.String; 44 | } 45 | } else { 46 | // only append new messages 47 | std.log.info("backup has already been done", .{}); 48 | var last_line: []const u8 = ""; 49 | while (try r.readUntilDelimiterOrEofAlloc(alloc, '\n', std.math.maxInt(usize))) |line| { 50 | last_line = line; 51 | } 52 | const val = try json.parse(alloc, last_line); 53 | const id = val.get("id").?.String; 54 | std.log.info("last saved id is {s}", .{id}); 55 | 56 | try f.seekTo(try f.getEndPos()); 57 | var next_flake = id; 58 | while (try discord.get_channel_messages(alloc, bot_token, channel_id, .after, next_flake)) |messages| { 59 | if (messages.len == 0) break; 60 | std.log.info("found {} messages starting at {s}, {} total so far", .{ messages.len, messages[0].get("id").?.String, message_list.items.len + messages.len }); 61 | try message_list.appendSlice(messages); 62 | if (messages.len < 50) break; 63 | std.time.sleep(std.time.ns_per_s); 64 | next_flake = messages[messages.len - 1].get("id").?.String; 65 | } 66 | } 67 | 68 | std.log.info("saving {} messages to disk", .{message_list.items.len}); 69 | for (range(message_list.items.len)) |_, i| { 70 | try w.print("{}\n", .{message_list.items[message_list.items.len - 1 - i]}); 71 | } 72 | 73 | std.log.info("done", .{}); 74 | } 75 | -------------------------------------------------------------------------------- /src/discord.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zfetch = @import("zfetch"); 3 | const json = @import("json"); 4 | 5 | pub const API_ROOT = "https://discord.com/api"; 6 | 7 | // 8 | fn do_discord_request(alloc: *std.mem.Allocator, method: zfetch.Method, url: []const u8, bot_token: []const u8) !json.Value { 9 | const req = try zfetch.Request.init(alloc, url, null); 10 | defer req.deinit(); 11 | 12 | var headers = zfetch.Headers.init(alloc); 13 | defer headers.deinit(); 14 | try headers.appendValue("Authorization", try std.mem.join(alloc, " ", &.{ "Bot", bot_token })); 15 | 16 | try req.do(method, headers, null); 17 | const r = req.reader(); 18 | 19 | const body_content = try r.readAllAlloc(alloc, std.math.maxInt(usize)); 20 | const val = json.parse(alloc, body_content) catch |e| { 21 | std.log.alert("caught error: {}, dumping response content", .{e}); 22 | std.log.alert("{s}", .{body_content}); 23 | return json.Value{ .Null = void{} }; 24 | }; 25 | return val; 26 | } 27 | // 28 | 29 | // https://discord.com/developers/docs/resources/guild#get-guild 30 | pub fn get_guild(alloc: *std.mem.Allocator, bot_token: []const u8, guild_id: []const u8) !?json.Value { 31 | const url = try std.mem.join(alloc, "/", &.{ API_ROOT, "guilds", guild_id }); 32 | const val = try do_discord_request(alloc, .GET, url, bot_token); 33 | return val; 34 | } 35 | 36 | // https://discord.com/developers/docs/resources/guild#get-guild-channels 37 | // GET/guilds/{guild.id}/channels 38 | pub fn get_guild_channels(alloc: *std.mem.Allocator, bot_token: []const u8, guild_id: []const u8) !?[]json.Value { 39 | const url = try std.mem.join(alloc, "/", &.{ API_ROOT, "guilds", guild_id, "channels" }); 40 | const val = try do_discord_request(alloc, .GET, url, bot_token); 41 | if (val != .Array) { 42 | std.log.err("got non array type from discord", .{}); 43 | std.log.err("{}", .{val}); 44 | return null; 45 | } 46 | return val.Array; 47 | } 48 | 49 | // https://discord.com/developers/docs/resources/channel#get-channel 50 | pub fn get_channel(alloc: *std.mem.Allocator, bot_token: []const u8, channel_id: []const u8) !?json.Value { 51 | const url = try std.mem.join(alloc, "/", &.{ API_ROOT, "channels", channel_id }); 52 | const val = try do_discord_request(alloc, .GET, url, bot_token); 53 | return val; 54 | } 55 | 56 | // https://discord.com/developers/docs/resources/channel#get-channel-messages 57 | pub fn get_channel_messages(alloc: *std.mem.Allocator, bot_token: []const u8, channel_id: []const u8, direction: enum { before, after }, flake: []const u8) !?[]json.Value { 58 | var url = try std.mem.join(alloc, "/", &.{ API_ROOT, "channels", channel_id, "messages" }); 59 | if (flake.len > 0) { 60 | url = try std.mem.join(alloc, "", &.{ url, "?", std.meta.tagName(direction), "=", flake }); 61 | } 62 | const val = try do_discord_request(alloc, .GET, url, bot_token); 63 | if (val != .Array) { 64 | std.log.err("got non array type from discord", .{}); 65 | std.log.err("{}", .{val}); 66 | return null; 67 | } 68 | return val.Array; 69 | } 70 | -------------------------------------------------------------------------------- /src/guild.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zfetch = @import("zfetch"); 3 | const json = @import("json"); 4 | 5 | const discord = @import("./discord.zig"); 6 | const channel = @import("./channel.zig"); 7 | 8 | pub fn execute(alloc: *std.mem.Allocator, args: [][]u8) !void { 9 | const bot_token = args[0]; 10 | const guild_id = args[1]; 11 | 12 | const guild = try discord.get_guild(alloc, bot_token, guild_id); 13 | if (guild.?.get("message")) |_| { 14 | std.log.warn("{}", .{guild}); 15 | return; 16 | } 17 | const name = guild.?.get("name").?.String; 18 | std.log.info("now backing up guild: {s} {s}", .{ guild_id, name }); 19 | 20 | if (try discord.get_guild_channels(alloc, bot_token, guild_id)) |response| { 21 | for (response) |chan| { 22 | const channel_id = chan.get("id").?.String; 23 | const ctype = chan.get("type").?.Int; 24 | if (ctype != 0) { 25 | continue; 26 | } 27 | try channel.do(alloc, bot_token, channel_id); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const ansi = @import("ansi"); 3 | 4 | pub fn main() !void { 5 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 6 | const alloc = &gpa.allocator; 7 | 8 | const proc_args = try std.process.argsAlloc(alloc); 9 | const args = proc_args[1..]; 10 | 11 | if (args.len == 0) { 12 | std.debug.print(ansi.style.FgRed, .{}); 13 | defer std.debug.print(ansi.style.ResetFgColor, .{}); 14 | 15 | std.log.info("must use a command", .{}); 16 | std.log.info("the available commands are:", .{}); 17 | std.log.info("\tguild BOT_TOKEN GUILD_ID", .{}); 18 | std.log.info("\tchannel BOT_TOKEN CHANNEL_ID...", .{}); 19 | return; 20 | } 21 | 22 | inline for (std.meta.declarations(commands)) |decl| { 23 | if (std.mem.eql(u8, args[0], decl.name)) { 24 | const cmd = @field(commands, decl.name); 25 | try cmd.execute(alloc, args[1..]); 26 | return; 27 | } 28 | } 29 | 30 | std.debug.print(ansi.style.FgRed, .{}); 31 | std.log.err("command '{s}' not found", .{args[0]}); 32 | std.debug.print(ansi.style.ResetFgColor, .{}); 33 | } 34 | 35 | pub const commands = struct { 36 | pub const guild = @import("./guild.zig"); 37 | pub const channel = @import("./channel.zig"); 38 | }; 39 | -------------------------------------------------------------------------------- /zig.mod: -------------------------------------------------------------------------------- 1 | id: c18kxcuuq3ggftftmofowagbraksbbw68kux3uruvog7rbmn 2 | name: discord-archiver 3 | license: AGPL-3.0 4 | description: Chat archiver for Discord 5 | bin: True 6 | provides: [ "discord-archiver" ] 7 | dev_dependencies: 8 | - src: git https://github.com/nektro/zig-ansi 9 | - src: git https://github.com/truemedian/zfetch 10 | - src: git https://github.com/nektro/zig-json 11 | - src: git https://github.com/nektro/zig-range 12 | -------------------------------------------------------------------------------- /zigmod.lock: -------------------------------------------------------------------------------- 1 | 2 2 | git https://github.com/nektro/zig-ansi commit-d4a53bcac5b87abecc65491109ec22aaf5f3dc2f 3 | git https://github.com/truemedian/zfetch commit-6ba2ba136ec7cfc887811039cd4a7d8a43ba725b 4 | git https://github.com/truemedian/hzzp commit-492107d44caa2676c7b5aa4e934e1e937232d652 5 | git https://github.com/alexnask/iguanaTLS commit-0d39a361639ad5469f8e4dcdaea35446bbe54b48 6 | git https://github.com/MasterQ32/zig-network commit-b9c91769d8ebd626c8e45b2abb05cbc28ccc50da 7 | git https://github.com/MasterQ32/zig-uri commit-52cdd2061bec0579519f0d30280597f3a1db8b75 8 | git https://github.com/nektro/zig-json commit-72e555fbc0776f2600aee19b01e5ab1855ebec7a 9 | git https://github.com/nektro/zig-range commit-890ca308fe09b3d5c866d5cfb3b3d7a95dbf939f 10 | -------------------------------------------------------------------------------- /zigmod.sum: -------------------------------------------------------------------------------- 1 | blake3-7fc0b46397932ea1f0726d42289606ca118cc745d88dd87c0d6a377ba7c6569f git/github.com/nektro/zig-ansi 2 | blake3-d7996d9432e92afdb399c51b3666cbbcf469ba9dcc8fbdba59274f5dc9cc7fa2 git/github.com/truemedian/zfetch 3 | blake3-98982125d0fbedc62e179e62081d2797a2b8a3623c42f9fd5d72cd56d6350714 git/github.com/truemedian/hzzp 4 | blake3-e6901bd7432450d5b22b01880cc7fa3fa2433e766a527206f18b29c67c1349bb git/github.com/alexnask/iguanaTLS 5 | blake3-21f91e48333ac0ca7f4704c96352831c25216e7056d02ce24de95d03fc942246 git/github.com/MasterQ32/zig-network 6 | blake3-030ebb03f1ed21122e681b06786bea6f2f1b810e8eb9f2029d0eee4f4fb3103f git/github.com/MasterQ32/zig-uri 7 | blake3-1893709ffc6359c5f9cd2f9409abccf78a94ed37bb2c6dd075c603356d17c94b git/github.com/nektro/zig-json 8 | blake3-09698753782139ab4877d08f33235170836f68b73e482b65cdee5637a6addf86 git/github.com/nektro/zig-range 9 | --------------------------------------------------------------------------------