Der billigste Hebel im Repo, und bis jetzt nur Vorarbeit: die Webhooks lagen seit der Discord-Einrichtung bereit, gepostet hat nie jemand. Ablauf: neuer Stream erkannt, eine Minute warten (Twitch setzt Titel und Kategorie erst kurz nach dem Start, wer sofort postet kuendigt den Titel des letzten Streams an), Daten frisch holen, Webhook posten, Rolle Live-Ping anpingen. Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkoerper. `<@&ID>` allein faerbt die Erwaehnung lila ein und benachrichtigt niemanden - der Fehler, den man erst nach drei leeren Streams bemerkt. Dagegen steht ein eigener Test. Gegen den doppelten Post stehen drei Dinge, denn wer zweimal in zehn Minuten angepingt wird, nimmt sich die Rolle wieder ab und ist damit fuer immer stumm: - Tabelle `posts` mit eindeutigem Index je Stream und Plattform. Nur *gesendete* Posts sperren; ein fehlgeschlagener Versuch darf wiederholt werden, sonst kostet ein Netzwerkaussetzer die ganze Ankuendigung. - Abklingzeit von sechs Stunden, gegen die neue Stream-ID nach einem Verbindungsabbruch. - Ausgeloest wird nur beim erkannten Neustart, nie im Fortsetzungszweig. `stream_id` ist bewusst kein Fremdschluessel: ein Post ist die Quittung fuer etwas, das den Rechner verlassen hat, und muss auch dann schreibbar sein, wenn es die Sitzung nicht gibt - beim Probelauf etwa. Dazu ein vierter Webhook in #technik-log. Der Entwurfsmodus postet dieselbe Nachricht dorthin, ohne Ping und nur fuer Mods sichtbar. So sieht man das Ergebnis, bevor es der Server sieht. Die Rollenaufloesung aus selfroles.ts liegt jetzt in roles.ts, weil der Ping sie auch braucht: ein Webhook kann eine Rolle nicht ueber ihren Namen anpingen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
// Rollen, die man sich selbst per Knopf gibt.
|
|
//
|
|
// Discords eigenes Onboarding kann das auch, verlangt dafuer aber sieben
|
|
// Vorgabekanaele, von denen fuenf beschreibbar sind. Der Server hat fuenf und
|
|
// drei - nachgemessen, nicht geraten (Fehlercode 350000). Zwei Kanaele nur
|
|
// anzulegen, damit eine Schwelle passt, macht den Server schlechter. Also ein
|
|
// Knopf unter dem Willkommenstext.
|
|
|
|
import { logger } from '#core/logger.ts';
|
|
import type { RestFull } from './api.ts';
|
|
import type { Interaction } from './gateway.ts';
|
|
import { SELF_ROLES, type SelfRolePlan } from './plan.ts';
|
|
import { createRoleResolver } from './roles.ts';
|
|
|
|
const log = logger('discord:rollen');
|
|
|
|
/** Interaktionsart: Klick auf eine Komponente. */
|
|
const MESSAGE_COMPONENT = 3;
|
|
/** Antwortart: sichtbare Antwort, hier zusaetzlich nur fuer den Klickenden. */
|
|
const CHANNEL_MESSAGE = 4;
|
|
const EPHEMERAL = 64;
|
|
|
|
/**
|
|
* Die Knopfleiste unter dem Willkommenstext.
|
|
* Style 2 ist der graue Knopf - er soll einladen, nicht schreien.
|
|
*/
|
|
export function selfRoleComponents(): Array<Record<string, unknown>> {
|
|
if (SELF_ROLES.length === 0) return [];
|
|
return [{
|
|
type: 1, // Reihe
|
|
components: SELF_ROLES.map((r) => ({
|
|
type: 2, // Knopf
|
|
style: 2,
|
|
label: r.label,
|
|
custom_id: r.customId,
|
|
emoji: { name: r.emoji },
|
|
})),
|
|
}];
|
|
}
|
|
|
|
export type RoleButtons = {
|
|
handle: (interaction: Interaction) => Promise<void>;
|
|
};
|
|
|
|
export function createRoleButtons(api: RestFull, guildId: string): RoleButtons {
|
|
// Ohne Bremse: ein Klick kommt von einem Menschen, und wenn eine Rolle
|
|
// gerade erst angelegt wurde, soll der naechste Klick sie finden.
|
|
const roles = createRoleResolver(api, guildId);
|
|
|
|
async function reply(interaction: Interaction, content: string): Promise<void> {
|
|
await api.post(`/interactions/${interaction.id}/${interaction.token}/callback`, {
|
|
type: CHANNEL_MESSAGE,
|
|
data: { content, flags: EPHEMERAL },
|
|
});
|
|
}
|
|
|
|
return {
|
|
async handle(interaction) {
|
|
if (interaction.type !== MESSAGE_COMPONENT) return;
|
|
const customId = interaction.data?.custom_id;
|
|
const plan: SelfRolePlan | undefined = SELF_ROLES.find((r) => r.customId === customId);
|
|
if (!plan) return;
|
|
|
|
const member = interaction.member;
|
|
if (!member) {
|
|
// Ausserhalb eines Servers gibt es keine Rollen zu vergeben.
|
|
await reply(interaction, 'Das geht nur im Server selbst.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const id = await roles.id(plan.role);
|
|
if (!id) {
|
|
await reply(interaction, `Die Rolle **${plan.role}** gibt es gerade nicht. Sag einem Mod Bescheid.`);
|
|
log.error(`Rolle "${plan.role}" nicht gefunden - Bauplan und Server laufen auseinander`);
|
|
return;
|
|
}
|
|
|
|
const user = member.user.id;
|
|
const path = `/guilds/${guildId}/members/${user}/roles/${id}`;
|
|
const hatSie = member.roles.includes(id);
|
|
|
|
if (hatSie) {
|
|
await api.del(path, 'Rolle per Knopf abgegeben');
|
|
await reply(interaction, plan.offText);
|
|
log.info(`${member.user.username} hat ${plan.role} abgegeben`);
|
|
} else {
|
|
await api.put(path, undefined, 'Rolle per Knopf geholt');
|
|
await reply(interaction, plan.onText);
|
|
log.info(`${member.user.username} hat ${plan.role} geholt`);
|
|
}
|
|
} catch (err) {
|
|
log.error(`Rollenwechsel fehlgeschlagen: ${(err as Error).message}`);
|
|
// Auf einen Klick gehoert eine Antwort, auch wenn es schiefging -
|
|
// sonst steht bei der Person nur "Interaktion fehlgeschlagen".
|
|
await reply(interaction, 'Hat nicht geklappt. Versuch es gleich noch einmal.')
|
|
.catch(() => undefined);
|
|
}
|
|
},
|
|
};
|
|
}
|