Skip to content

Commit 188460f

Browse files
committed
v3.1.0: Add migrate channel include-list
Implements #24. Add an option to only migrate text-channels.
1 parent 5f3099e commit 188460f

File tree

4 files changed

+123
-61
lines changed

4 files changed

+123
-61
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ _A discord bot for copying messages between guilds_
33

44
[![build](https://github.com/BilliAlpha/discord-transfer/actions/workflows/maven.yml/badge.svg)](https://github.com/BilliAlpha/discord-transfer/actions/workflows/maven.yml)
55

6-
**Current version: [v3.0.2](https://github.com/BilliAlpha/discord-transfer/releases/latest)**
6+
**Current version: [v3.1.0](https://github.com/BilliAlpha/discord-transfer/releases/latest)**
77

88
## How to use ? ##
99

@@ -56,8 +56,10 @@ The migrate action takes 2 arguments:
5656
2. The Discord ID of the destination Guild (the one in which messages will be copied)
5757

5858
There are also options to customize the migration behavior:
59+
- `--include-channel`: Specify channels that should be migrated, it's category will automatically be created if missing, expects a Discord channel ID
5960
- `--category`: Specify specific channel categories to migrate, expects a Discord category ID
6061
- `--skip-channel`: Specify channels that should not be migrated, expects a Discord channel ID
62+
- `--text-only`: Will only migrate text channels (skips voice channel creation)
6163
- `--after`: Only migrate messages after the give date (format ISO-8601, ex: `1997−07−16T19:20:30,451Z`)
6264
- `--delay`: Add a delay between each message migration
6365

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.billialpha.discord</groupId>
88
<artifactId>discord-transfer</artifactId>
9-
<version>3.0.2</version>
9+
<version>3.1.0</version>
1010

1111
<properties>
1212
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

src/main/java/com/billialpha/discord/transfer/DiscordTransfer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* @author BilliAlpha <billi.pamege.300@gmail.com>
1919
*/
2020
public class DiscordTransfer {
21-
public static final String VERSION = "3.0.2";
21+
public static final String VERSION = "3.1.0";
2222
private static final Logger LOGGER = LoggerFactory.getLogger(DiscordTransfer.class);
2323
public static final Map<String, Command.Description> ACTIONS = new HashMap<>();
2424
static {

src/main/java/com/billialpha/discord/transfer/commands/MigrateCommand.java

Lines changed: 118 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,8 @@
4141
import java.util.ArrayList;
4242
import java.util.HashSet;
4343
import java.util.List;
44-
import java.util.Optional;
4544
import java.util.Set;
4645
import java.util.concurrent.CompletableFuture;
47-
import java.util.function.Function;
4846

4947
public class MigrateCommand extends Command {
5048
private static final Logger LOGGER = LoggerFactory.getLogger(MigrateCommand.class);
@@ -63,10 +61,13 @@ public class MigrateCommand extends Command {
6361
"Limit the migration to specific categories", Snowflake::of)
6462
.withArrayOption("skip-channel", "s",
6563
"Ignore this channel during migration", Snowflake::of)
64+
.withArrayOption("include-channel", "i",
65+
"Include this channel during migration", Snowflake::of)
6666
.withOption("after", "a",
6767
"Only migrate messages after the given date", Instant::parse)
6868
.withOption("delay", "d",
6969
"Pause between each message posted", Integer::parseUnsignedInt, 0)
70+
.withFlag("text-only", null,"Only migrate text channels")
7071
.withFlag("no-bot", null,"Do not copy bot messages")
7172
.withFlag("no-reupload", null, "Do not re-upload attachments")
7273
.build(),
@@ -77,24 +78,28 @@ public class MigrateCommand extends Command {
7778
private final Guild srcGuild;
7879
private final Guild destGuild;
7980
private final Set<Snowflake> skipChannels;
81+
private final Set<Snowflake> includeChannels;
8082
private final Set<Snowflake> categories;
8183
private final Instant afterDate;
8284
private final int delay;
8385
private final boolean reUploadFiles;
8486
private final boolean noBotMessages;
87+
private final boolean textOnly;
8588
private final int verbosity;
8689
private final Scheduler scheduler;
8790

8891
public MigrateCommand(Invocation params) {
8992
this.client = params.client;
9093
this.skipChannels = new HashSet<>(params.getList("skip-channel"));
94+
this.includeChannels = new HashSet<>(params.getList("include-channel"));
9195
this.categories = new HashSet<>(params.getList("category"));
9296
this.afterDate = params.get("after");
9397
this.delay = params.get("delay");
9498
this.reUploadFiles = !params.hasFlag("no-reupload");
9599
this.verbosity = params.get("verbose");
96100
this.scheduler = Schedulers.parallel();
97101
this.noBotMessages = params.hasFlag("no-bot");
102+
this.textOnly = params.hasFlag("text-only");
98103

99104
Snowflake srcGuildId = params.get("source");
100105
try {
@@ -116,43 +121,54 @@ public MigrateCommand(Invocation params) {
116121
@Override
117122
public void execute() {
118123
LOGGER.info("Starting migration ...");
119-
Optional<Long> migrated = getSelectedCategories()
124+
125+
if (!textOnly) {
126+
LOGGER.info("Creating categories and voice channels in destination guild");
127+
long migratedVoiceChans = getSelectedCategories()
128+
.parallel()
129+
.runOn(scheduler)
130+
.flatMap(this::migrateCategory)
131+
.reduce(Long::sum)
132+
.blockOptional()
133+
.orElse(0L);
134+
if (migratedVoiceChans > 0) {
135+
LOGGER.info("Successfully created "+migratedVoiceChans+" voice channels");
136+
} else {
137+
LOGGER.info("No voice channel created");
138+
}
139+
}
140+
141+
LOGGER.info("Migrating text channels");
142+
long migratedMessages = getSelectedTextChannels()
120143
.parallel()
121144
.runOn(scheduler)
122-
.flatMap(srcCat ->
123-
destGuild.getChannels().ofType(Category.class)
124-
.filter(c -> c.getName().equals(srcCat.getName()))
125-
.singleOrEmpty()
126-
.switchIfEmpty(
127-
destGuild.createCategory(srcCat.getName()))
128-
.flatMapMany(dstCat -> migrateCategory(srcCat, dstCat))
129-
)
145+
.flatMap(c -> this.migrateTextChannel(c).onErrorContinue((err, x) ->
146+
LOGGER.warn("Error in text channel migration (" + c.getName() + "):", err)))
147+
.map(TextChannelMigrationResult::messageCount)
130148
.reduce(Long::sum)
131-
.blockOptional();
132-
133-
if (migrated.isPresent() && migrated.get() > 0) {
134-
LOGGER.info("Migration finished successfully ("+migrated.get()+" messages)");
149+
.blockOptional()
150+
.orElse(0L);
151+
if (migratedMessages > 0) {
152+
LOGGER.info("Successfully migrated "+migratedMessages+" messages");
135153
} else {
136-
LOGGER.info("Migration finished without migrating any message");
154+
LOGGER.info("No message migrated");
137155
}
138156

139157
client.logout().block();
140158
LOGGER.debug("Logged out");
141159
}
142160

143-
private Mono<Long> migrateCategory(@NonNull Category srcCat, @NonNull Category dstCat) {
161+
private Mono<Long> migrateCategory(@NonNull Category srcCat) {
144162
LOGGER.info("Migrating category: "+srcCat.getName()+" ("+srcCat.getId().asString()+")");
145-
return migrateCategoryVoiceChannels(srcCat, dstCat)
146-
.then(migrateCategoryTextChannels(srcCat, dstCat)
147-
.map(TextChannelMigrationResult::messageCount)
148-
.reduce(Long::sum))
149-
.onErrorResume(err -> {
150-
LOGGER.warn("Error in category migration", err);
151-
return Mono.empty();
152-
});
163+
return destGuild.getChannels().ofType(Category.class)
164+
.filter(c -> c.getName().equals(srcCat.getName()))
165+
.singleOrEmpty()
166+
.switchIfEmpty(destGuild.createCategory(srcCat.getName()))
167+
.flatMapMany(dstCat -> migrateCategoryVoiceChannels(srcCat, dstCat))
168+
.reduce(Long::sum);
153169
}
154170

155-
private Mono<Void> migrateCategoryVoiceChannels(@NonNull Category srcCat, @NonNull Category dstCat) {
171+
private Mono<Long> migrateCategoryVoiceChannels(@NonNull Category srcCat, @NonNull Category dstCat) {
156172
return srcCat.getChannels().ofType(VoiceChannel.class)
157173
// Filter on non-migrated channels
158174
.filterWhen(srcChan -> dstCat.getChannels()
@@ -166,34 +182,42 @@ private Mono<Void> migrateCategoryVoiceChannels(@NonNull Category srcCat, @NonNu
166182
.name(srcChan.getName())
167183
.parentId(dstCat.getId())
168184
.position(srcChan.getRawPosition()).build()))
169-
.then();
185+
.count();
170186
}
171187

172-
private Flux<TextChannelMigrationResult> migrateCategoryTextChannels(@NonNull Category srcCat, @NonNull Category dstCat) {
173-
return srcCat.getChannels().ofType(TextChannel.class)
174-
.parallel().runOn(scheduler)
175-
// Filter on non-skipped channels
176-
.filter(c -> !skipChannels.contains(c.getId()))
177-
.flatMap(srcChan -> dstCat.getChannels()
178-
.ofType(TextChannel.class)
179-
.filter(c -> c.getName().equals(srcChan.getName()))
180-
.singleOrEmpty()
181-
.switchIfEmpty(destGuild.createTextChannel(TextChannelCreateSpec.builder()
182-
.name(srcChan.getName())
183-
.topic(srcChan.getTopic().map(Possible::of).orElse(Possible.absent()))
184-
.nsfw(srcChan.isNsfw())
185-
.parentId(dstCat.getId())
186-
.position(srcChan.getRawPosition())
187-
.build()))
188-
.zipWith(Mono.just(srcChan)))
189-
// T1 = dest chan, T2 = src chan
190-
.flatMap(tuple -> migrateTextChannel(tuple.getT2(), tuple.getT1())
191-
.count()
192-
.map(count -> new TextChannelMigrationResult(tuple.getT2(), tuple.getT1(), count)))
193-
.groups().flatMap(Flux::collectList).flatMapIterable(Function.identity())
194-
.onErrorContinue((err, x) -> LOGGER.warn("Error in channel migration", err));
188+
private Flux<TextChannelMigrationResult> migrateTextChannel(@NonNull TextChannel srcChan) {
189+
return srcChan.getCategory()
190+
.flatMapMany(srcCat ->
191+
// Find destination category
192+
destGuild.getChannels()
193+
.ofType(Category.class)
194+
// Filter on name
195+
.filter(cat -> srcCat.getName().equals(cat.getName()))
196+
// Create category if it doesn't exist
197+
.switchIfEmpty(destGuild.createCategory(srcCat.getName()))
198+
.flatMap(dstCat ->
199+
// Find destination channel
200+
dstCat.getChannels()
201+
.ofType(TextChannel.class)
202+
// Filter on name
203+
.filter(c -> c.getName().equals(srcChan.getName()))
204+
.singleOrEmpty()
205+
// Create channel if it doesn't exist
206+
.switchIfEmpty(destGuild.createTextChannel(TextChannelCreateSpec.builder()
207+
.name(srcChan.getName())
208+
.topic(srcChan.getTopic().map(Possible::of).orElse(Possible.absent()))
209+
.nsfw(srcChan.isNsfw())
210+
.parentId(dstCat.getId())
211+
.position(srcChan.getRawPosition())
212+
.build()))
213+
)
214+
)
215+
// Migrate channel messages
216+
.flatMap(dstChan -> migrateTextChannelMessages(srcChan, dstChan));
195217
}
196-
private Flux<Message> migrateTextChannel(@NonNull TextChannel srcChan, @NonNull TextChannel dstChan) {
218+
private Mono<TextChannelMigrationResult> migrateTextChannelMessages(
219+
@NonNull TextChannel srcChan, @NonNull TextChannel dstChan
220+
) {
197221
LOGGER.info("Migrating channel: "+srcChan.getName()+" ("+srcChan.getId().asString()+")");
198222
Snowflake startDate = getChannelStartDate(srcChan.getId());
199223
LOGGER.debug("Channel date: "+startDate.getTimestamp());
@@ -202,7 +226,9 @@ private Flux<Message> migrateTextChannel(@NonNull TextChannel srcChan, @NonNull
202226
return flux.filter(m -> m.getReactions().stream() // Filter on non migrated messages
203227
.filter(Reaction::selfReacted)
204228
.noneMatch(r -> r.getEmoji().equals(MIGRATED_EMOJI)))
205-
.flatMap(m -> migrateMessage(m, dstChan)); // Perform migration
229+
.flatMap(m -> migrateMessage(m, dstChan)) // Perform migration
230+
.count()
231+
.map(count -> new TextChannelMigrationResult(srcChan, dstChan, count));
206232
}
207233

208234
private Mono<Message> migrateMessage(@NonNull Message msg, @NonNull TextChannel dstChan) {
@@ -350,13 +376,47 @@ private Snowflake getChannelStartDate(Snowflake chanId) {
350376
: chanId;
351377
}
352378

379+
/**
380+
* The list of text channels to migrate.
381+
* <p>
382+
* This list includes all text channels from the selected categories
383+
* (see {@link MigrateCommand#getSelectedCategories()} excluding skipped channels
384+
* to which explicitly included channels are added.
385+
* </p>
386+
* @return A flux of selected text channels in the source guild.
387+
*/
388+
private Flux<TextChannel> getSelectedTextChannels() {
389+
return Flux.concat(
390+
getSelectedCategories()
391+
.flatMap(Category::getChannels)
392+
.ofType(TextChannel.class),
393+
Mono.justOrEmpty(includeChannels)
394+
.flatMapMany(Flux::fromIterable)
395+
.flatMap(srcGuild::getChannelById)
396+
.ofType(TextChannel.class)
397+
).filter(c -> !skipChannels.contains(c.getId()));
398+
}
399+
400+
/**
401+
* The list of categories to migrate.
402+
* <p>
403+
* If at least one category was specified in parameter then only return
404+
* explicitly selected categories, otherwise, and if no explicit channel are included,
405+
* return all categories present in the source guild.
406+
* </p>
407+
* @return A flux of selected categories in the source guild.
408+
*/
353409
private Flux<Category> getSelectedCategories() {
354-
if (categories != null) {
355-
return Flux.fromIterable(categories)
356-
.flatMap(srcGuild::getChannelById)
357-
.ofType(Category.class);
358-
}
359-
return srcGuild.getChannels().ofType(Category.class);
410+
return Mono.justOrEmpty(categories)
411+
.flatMapMany(Flux::fromIterable)
412+
.flatMap(srcGuild::getChannelById)
413+
.ofType(Category.class)
414+
// If no catagory was selected
415+
.switchIfEmpty(includeChannels != null && includeChannels.size() > 0
416+
// But there are included channels, no categories
417+
? Flux.empty()
418+
// Otherwise, return all existing categories
419+
: srcGuild.getChannels().ofType(Category.class));
360420
}
361421

362422
public record TextChannelMigrationResult(TextChannel sourceChan, TextChannel destChan, long messageCount) {}

0 commit comments

Comments
 (0)