Show title name (details) under name in Discord

This commit is contained in:
Samuel Elliott 2025-08-05 15:43:53 +01:00
parent 56086c0651
commit be21d69a4c
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
8 changed files with 224 additions and 28 deletions

View File

@ -13,7 +13,7 @@ import { CurrentUser, Friend, Game, PresencePlatform, PresenceState, WebService
import { NintendoAccountSessionTokenJwtPayload, NintendoAccountUser } from '../../api/na.js';
import { DiscordPresence } from '../../discord/types.js';
import { getDiscordRpcClients } from '../../discord/rpc.js';
import { defaultTitle } from '../../discord/titles.js';
import { default_client } from '../../discord/titles.js';
import type { FriendProps } from '../browser/friend/index.js';
import type { DiscordSetupProps } from '../browser/discord/index.js';
import type { AddFriendProps } from '../browser/add-friend/index.js';
@ -181,7 +181,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
for (const client of await getDiscordRpcClients()) {
try {
await client.connect(defaultTitle.client);
await client.connect(default_client);
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
} finally {
await client.destroy();

View File

@ -1,6 +1,6 @@
import type { Arguments as ParentArguments } from './index.js';
import { DiscordRpcClient, getAllIpcSockets } from '../../discord/rpc.js';
import { defaultTitle } from '../../discord/titles.js';
import { default_client } from '../../discord/titles.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
@ -16,8 +16,6 @@ export function builder(yargs: Argv<ParentArguments>) {
type Arguments = YargsArguments<ReturnType<typeof builder>>;
const CLIENT_ID = defaultTitle.client;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const sockets = await getAllIpcSockets();
@ -26,7 +24,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
for (const [id, socket] of sockets) {
const client = new DiscordRpcClient({ transport: 'ipc', ipc_socket: socket });
await client.connect(CLIENT_ID);
await client.connect(default_client);
debug('[%d] Connected', id);
if (client.application) {

View File

@ -2,7 +2,7 @@ import { fetch } from 'undici';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { titles as unsorted_titles } from '../../discord/titles.js';
import { default_client, titles as unsorted_titles } from '../../discord/titles.js';
import { DiscordApplicationRpc, getDiscordApplicationRpc } from './discord-activity.js';
import { Title } from '../../discord/types.js';
@ -153,13 +153,13 @@ async function getGroupedTitlesJson(exclude_discord_configuration = false, inclu
}[] = [];
for (const title of titles) {
let client = clients.find(c => c.id === title.client);
let client = clients.find(c => c.id === (title.client ?? default_client));
if (!client) {
const application = await getDiscordApplicationRpc(title.client);
const application = await getDiscordApplicationRpc(title.client ?? default_client);
client = {
id: title.client,
id: title.client ?? default_client,
application,
titles: [],
};

View File

@ -47,7 +47,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
warn('Invalid title ID, must be lowercase hex', title.id);
else warn('Invalid title ID', title.id);
}
if (!title.client.match(/^\d{16,}$/)) warn('Invalid Discord client ID', title.id, title.client);
if (title.client && !title.client.match(/^\d{16,}$/)) warn('Invalid Discord client ID', title.id, title.client);
if (has_errors) continue;

View File

@ -56,6 +56,29 @@ export async function findDiscordRpcClient(
// Patches discord-rpc to allow using a specific socket.
//
export interface DiscordRpcClient {
/**
* Request
* @param {string} cmd Command
* @param {Object} [args={}] Arguments
* @param {string} [evt] Event
* @returns {Promise}
* @private
*/
request(cmd: string, args?: object, evt?: string): Promise<unknown>;
}
declare module 'discord-rpc' {
interface Presence {
name?: string;
statusDisplayType?: DiscordApiActivityStatusDisplayType;
stateUrl?: string;
detailsUrl?: string;
largeImageUrl?: string;
smallImageUrl?: string;
}
}
export class DiscordRpcClient extends DiscordRPC.Client {
constructor(options?: DiscordRPC.RPCClientOptions & {
ipc_socket?: net.Socket;
@ -74,6 +97,149 @@ export class DiscordRpcClient extends DiscordRPC.Client {
this.transport.on('message', this._onRpcMessage.bind(this));
}
}
setActivity(args: DiscordRPC.Presence, pid = process.pid) {
const activity: DiscordRpcActivity = {
name: args.name,
type: DiscordApiActivityType.PLAYING,
status_display_type: args.statusDisplayType,
state: args.state,
state_url: args.stateUrl,
details: args.details,
details_url: args.detailsUrl,
buttons: args.buttons,
instance: !!args.instance,
};
if (args.startTimestamp || args.endTimestamp) {
activity.timestamps = {
start: args.startTimestamp instanceof Date ? Math.round(args.startTimestamp.getTime()) : args.startTimestamp,
end: args.endTimestamp instanceof Date ? Math.round(args.endTimestamp.getTime()) : args.endTimestamp,
};
if (typeof activity.timestamps.start === 'number' && activity.timestamps.start > 2147483647000) {
throw new RangeError('timestamps.start must fit into a unix timestamp');
}
if (typeof activity.timestamps.end === 'number' && activity.timestamps.end > 2147483647000) {
throw new RangeError('timestamps.end must fit into a unix timestamp');
}
}
if (args.largeImageKey || args.largeImageText ||
args.smallImageKey || args.smallImageText
) {
activity.assets = {
large_image: args.largeImageKey,
large_text: args.largeImageText,
large_url: args.largeImageUrl,
small_image: args.smallImageKey,
small_text: args.smallImageText,
small_url: args.smallImageUrl,
};
}
if (args.partySize || args.partyId || args.partyMax) {
activity.party = {
id: args.partyId,
size: args.partySize || args.partyMax ? [args.partySize ?? 0, args.partyMax ?? 0] : undefined,
};
}
if (args.matchSecret || args.joinSecret || args.spectateSecret) {
activity.secrets = {
match: args.matchSecret,
join: args.joinSecret,
spectate: args.spectateSecret,
};
}
return this.setActivityRaw(activity, pid);
}
setActivityRaw(activity: DiscordRpcActivity, pid = process.pid) {
debug('set activity', activity);
return this.request('SET_ACTIVITY', {
pid,
activity,
});
}
}
type DiscordRpcActivity = Partial<Omit<DiscordApiActivity, 'created_at' | 'application_id' | 'emoji'>>;
interface DiscordApiActivity {
name: string;
type: DiscordApiActivityType;
url?: string;
created_at: number;
timestamps?: DiscordApiActivityTimestamps;
application_id?: string;
status_display_type?: DiscordApiActivityStatusDisplayType;
details?: string;
details_url?: string;
state?: string;
state_url?: string;
emoji?: DiscordApiActivityEmoji;
party?: DiscordApiActivityParty;
assets?: DiscordApiActivityAssets;
secrets?: DiscordApiActivitySecrets;
instance?: boolean;
flags?: number;
buttons?: DiscordApiActivityButton[];
}
enum DiscordApiActivityType {
PLAYING = 0,
STREAMING = 1,
LISTENING = 2,
WATCHING = 3,
CUSTOM = 4,
COMPETING = 5,
}
export enum DiscordApiActivityStatusDisplayType {
NAME = 0,
STATE = 1,
DETAILS = 2,
}
interface DiscordApiActivityTimestamps {
start?: number;
end?: number;
}
interface DiscordApiActivityEmoji {
name: string;
id?: string;
animated?: boolean;
}
interface DiscordApiActivityParty {
id?: string;
size?: [number, number];
}
interface DiscordApiActivityAssets {
large_image?: string;
large_text?: string;
large_url?: string;
small_image?: string;
small_text?: string;
small_url?: string;
}
interface DiscordApiActivitySecrets {
join?: string;
spectate?: string;
match?: string;
}
enum DiscordApiActivityFlags {
INSTANCE = 1 << 0,
JOIN = 1 << 1,
SPECTATE = 1 << 2,
JOIN_REQUEST = 1 << 3,
SYNC = 1 << 4,
PLAY = 1 << 5,
PARTY_PRIVACY_FRIENDS = 1 << 6,
PARTY_PRIVACY_VOICE_CHANNEL = 1 << 7,
EMBEDDED = 1 << 8,
}
interface DiscordApiActivityButton {
label: string;
url: string;
}
class IpcTransport extends BaseIpcTransport {

View File

@ -2,19 +2,20 @@ import { Title } from './types.js';
import * as publishers from './titles/index.js';
import { PresencePlatform } from '../api/coral-types.js';
export const defaultTitle: Title = {
id: '0000000000000000',
client: '950883021165330493',
titleName: true,
showPlayingOnline: true,
showActiveEvent: true,
};
export const platform_clients: Record<PresencePlatform, string> = {
[PresencePlatform.NX]: '950883021165330493',
[PresencePlatform.OUNCE]: '1358060657957928970',
};
export const default_client = platform_clients[PresencePlatform.NX];
export const defaultTitle: Title = {
id: '0000000000000000',
titleName: true,
showPlayingOnline: true,
showActiveEvent: true,
};
export const titles: Title[] = [];
for (const [publisher, m] of Object.entries(publishers)) {

View File

@ -27,7 +27,9 @@ export interface DiscordPresence {
type SystemModuleTitleId = `01000000000000${string}`;
type SystemDataTitleId = `01000000000008${string}`;
type SystemAppletTitleId = `0100000000001${string}`;
type ApplicationTitleId = `0100${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`;
type ApplicationTitleIdNx = `0100${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`;
type ApplicationTitleIdOunce = `0400${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`;
type ApplicationTitleId = ApplicationTitleIdNx | ApplicationTitleIdOunce;
export interface Title<M extends ExternalMonitor = ExternalMonitor> {
/**
@ -39,12 +41,18 @@ export interface Title<M extends ExternalMonitor = ExternalMonitor> {
/**
* Discord client ID
*/
client: string;
client?: string;
/**
* Activity name. This overrides the application's name that will appear under the user's name after "Playing ".
*/
setActivityName?: boolean;
activityName?: string;
/**
* Title name to show in Discord. This is *not* the name that will appear under the user's name after "Playing ".
*
* If this is set to true the title's name from znc will be used.
* If this is set to true the title's name from coral will be used.
* If this is set to false (default) no title name will be set. This should be used when a specific Discord client for the title is used.
* If this is set to a string it will be used as the title name.
*
@ -52,7 +60,7 @@ export interface Title<M extends ExternalMonitor = ExternalMonitor> {
*/
titleName?: string | boolean;
/**
* By default the title's icon from znc will be used. (No icons need to be uploaded to Discord.)
* By default the title's icon from coral will be used. (No icons need to be uploaded to Discord.)
*/
largeImageKey?: string;
largeImageText?: string;

View File

@ -1,10 +1,11 @@
import DiscordRPC from 'discord-rpc';
import { PresenceGame, PresenceState } from '../api/coral-types.js';
import { defaultTitle, platform_clients, titles } from './titles.js';
import { PresenceGame, PresencePlatform, PresenceState } from '../api/coral-types.js';
import { default_client, defaultTitle, platform_clients, titles } from './titles.js';
import createDebug from '../util/debug.js';
import { product, version } from '../util/product.js';
import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js';
import { DiscordPresence, DiscordPresenceContext, DiscordPresencePlayTime } from './types.js';
import { DiscordApiActivityStatusDisplayType } from './rpc.js';
const debug = createDebug('nxapi:discord');
@ -44,9 +45,18 @@ export function getDiscordPresence(
const activity = new DiscordActivity();
if (title.setActivityName) {
activity.name = title.activityName ?? game.name;
} else if (title.titleName) {
// If this is set it/the title name is used as the details field
activity.statusDisplayType = DiscordApiActivityStatusDisplayType.DETAILS;
}
activity.details = text[0];
activity.state = text[1];
activity.platform = context?.platform;
activity.setLargeImage(title.largeImageKey ?? game.imageUri, title.largeImageText);
if (title.smallImageKey) {
@ -78,7 +88,7 @@ export function getDiscordPresence(
return {
id: (title !== defaultTitle ? title : null)?.client ||
(typeof context?.platform !== 'undefined' && platform_clients[context.platform]) ||
defaultTitle.client,
defaultTitle.client || default_client,
title: titleid,
config: title,
activity,
@ -87,6 +97,8 @@ export function getDiscordPresence(
}
export class DiscordActivity implements DiscordRPC.Presence {
name?: string = undefined;
statusDisplayType?: DiscordApiActivityStatusDisplayType | undefined;
details?: string = undefined;
state?: string = undefined;
largeImageKey?: string = undefined;
@ -95,13 +107,24 @@ export class DiscordActivity implements DiscordRPC.Presence {
smallImageText?: string = undefined;
buttons: { label: string; url: string; }[] = [];
platform?: PresencePlatform;
constructor() {
//
}
get large_image_default_text() {
let text = product;
if (this.platform === PresencePlatform.NX) text = 'Playing on Nintendo Switch | ' + text;
if (this.platform === PresencePlatform.OUNCE) text = 'Playing on Nintendo Switch 2 | ' + text;
return text;
}
setLargeImage(key: string, text?: string) {
this.largeImageKey = key;
this.largeImageText = text ? text + ' | ' + product : product;
this.largeImageText = text ? text + ' | ' + this.large_image_default_text : this.large_image_default_text;
}
setSmallImage(key: string, text?: string) {
@ -174,7 +197,7 @@ export function getInactiveDiscordPresence(
): DiscordPresence {
return {
id: (typeof context?.platform !== 'undefined' && platform_clients[context.platform]) ||
defaultTitle.client,
defaultTitle.client || default_client,
title: null,
activity: {
state: 'Not playing',