Compare commits

...
2 Commits
Author SHA1 Message Date
devlikepro fb3aa59683 [core] 2024.7.5
Release / amd64 - chrome - chrome (push) Waiting to run
Release / amd64 - chromium - latest (push) Waiting to run
Release / linux/arm64 - chromium - arm (push) Waiting to run
Release / amd64 - none - noweb (push) Waiting to run
Release / linux/arm64 - none - noweb-arm (push) Waiting to run
2024-07-15 16:03:08 +03:00
devlikepro b938792ca8 [core] channels 2024-07-15 16:02:51 +03:00
11 changed files with 501 additions and 21 deletions

No files matched your search

+2 -2
View File
@@ -32,8 +32,8 @@ for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true . ${NVM_DIR}/nvm.sh && nvm exec yarn start
update-dependencies:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js#fork-webpack-exodus
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-webpack-exodus-channels
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-channels
. ${NVM_DIR}/nvm.sh && nvm exec yarn up venom-bot
start-proxy:
+2 -2
View File
@@ -22,7 +22,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-channels",
"@adiwajshing/keyed-db": "^0.2.4",
"@nestjs/axios": "^3.0.2",
"@nestjs/common": "^9.0.9",
@@ -70,7 +70,7 @@
"sharp": "^0.33.4",
"swagger-ui-express": "^4.1.4",
"venom-bot": "5.0.1",
"whatsapp-web.js": "https://github.com/devlikeapro/whatsapp-web.js#fork-webpack-exodus"
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-webpack-exodus-channels"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
+119
View File
@@ -0,0 +1,119 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
Channel,
CreateChannelRequest,
ListChannelsQuery,
NewsletterIdApiParam,
NewsletterIdOrInviteCodeApiParam,
} from '@waha/structures/channels.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { isNewsletter, WhatsappSession } from '../core/abc/session.abc';
import { SessionApiParam, SessionParam } from './helpers';
@ApiSecurity('api_key')
@Controller('api/:session/channels')
@ApiTags('channels')
export class ChannelsController {
constructor(private manager: SessionManager) {}
@Get('')
@SessionApiParam
@ApiOperation({ summary: 'Get list of know channels' })
async list(
@SessionParam session: WhatsappSession,
@Query() query: ListChannelsQuery,
): Promise<Channel[]> {
return session.channelsList(query);
}
@Post('')
@SessionApiParam
@ApiOperation({ summary: 'Create a new channel.' })
create(
@SessionParam session: WhatsappSession,
@Body() request: CreateChannelRequest,
): Promise<Channel> {
return session.channelsCreateChannel(request);
}
@Delete(':id')
@SessionApiParam
@NewsletterIdApiParam
@ApiOperation({ summary: 'Delete the channel.' })
delete(@SessionParam session: WhatsappSession, @Param('id') id: string) {
return session.channelsDeleteChannel(id);
}
@Get(':id')
@SessionApiParam
@NewsletterIdOrInviteCodeApiParam
@ApiOperation({
summary: 'Get the channel info - either by id @newsletter OR invite code.',
})
get(
@SessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<Channel> {
if (isNewsletter(id)) {
return session.channelsGetChannel(id);
} else {
// https://www.whatsapp.com/channel/123 => 123
const inviteCode = id.split('/').pop();
return session.channelsGetChannelByInviteCode(inviteCode);
}
}
@Post('follow')
@SessionApiParam
@NewsletterIdApiParam
@ApiOperation({ summary: 'Follow the channel.' })
follow(
@SessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<void> {
return session.channelsFollowChannel(id);
}
@Post('unfollow')
@SessionApiParam
@NewsletterIdApiParam
@ApiOperation({ summary: 'Unfollow the channel.' })
unfollow(
@SessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<void> {
return session.channelsUnfollowChannel(id);
}
@Post('mute')
@SessionApiParam
@NewsletterIdApiParam
@ApiOperation({ summary: 'Mute the channel.' })
mute(
@SessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<void> {
return session.channelsMuteChannel(id);
}
@Post('unmute')
@SessionApiParam
@NewsletterIdApiParam
@ApiOperation({ summary: 'Unmute the channel.' })
unmute(
@SessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<void> {
return session.channelsUnmuteChannel(id);
}
}
+1
View File
@@ -60,6 +60,7 @@ export class SwaggerConfiguratorCore {
.addTag('auth', 'Authentication')
.addTag('screenshot', 'Get screenshot of WhatsApp and show QR code')
.addTag('chatting', 'Chatting methods')
.addTag('channels', 'Channels (newsletters) methods')
.addTag(
'status',
'Status (aka stories) methods. <b>NOWEB</b> engine only!',
+54
View File
@@ -1,3 +1,8 @@
import {
Channel,
CreateChannelRequest,
ListChannelsQuery,
} from '@waha/structures/channels.dto';
import { GetChatsQuery } from '@waha/structures/chats.dto';
import { LoggerBuilder } from '@waha/utils/logging';
import { EventEmitter } from 'events';
@@ -437,6 +442,47 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
/**
* Channels methods
*/
public channelsList(query: ListChannelsQuery): Promise<Channel[]> {
throw new NotImplementedByEngineError();
}
public channelsCreateChannel(
request: CreateChannelRequest,
): Promise<Channel> {
throw new NotImplementedByEngineError();
}
public channelsGetChannel(id: string): Promise<Channel> {
throw new NotImplementedByEngineError();
}
public channelsGetChannelByInviteCode(inviteCode: string): Promise<Channel> {
throw new NotImplementedByEngineError();
}
public channelsDeleteChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsFollowChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsUnfollowChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsMuteChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsUnmuteChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
/**
* Status methods
*/
@@ -502,3 +548,11 @@ export abstract class WhatsappSession {
qrcode.generate(qr.raw, { small: true });
}
}
export function isNewsletter(jid: string) {
return jid.endsWith('@newsletter');
}
export function getChannelInviteLink(code: string) {
return `https://whatsapp.com/channel/${code}`;
}
+2
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TerminusModule } from '@nestjs/terminus';
import { BufferJsonReplacerInterceptor } from '@waha/api/BufferJsonReplacerInterceptor';
import { ChannelsController } from '@waha/api/channels.controller';
import { WebsocketGatewayCore } from '@waha/core/api/websocket.gateway.core';
import { getPinoLogLevel, getPinoTransport } from '@waha/utils/logging';
import { LoggerModule } from 'nestjs-pino';
@@ -79,6 +80,7 @@ export const CONTROLLERS = [
SessionController,
ChattingController,
ChatsController,
ChannelsController,
StatusController,
ContactsController,
GroupsController,
+101 -5
View File
@@ -5,11 +5,13 @@ import makeWASocket, {
extractMessageContent,
getAggregateVotesInPollMessage,
getKeyAuthor,
getUrlFromDirectPath,
isJidGroup,
isJidStatusBroadcast,
isRealMessage,
jidNormalizedUser,
makeCacheableSignalKeyStore,
NewsletterMetadata,
normalizeMessageContent,
PresenceData,
proto,
@@ -22,6 +24,12 @@ import { UnprocessableEntityException } from '@nestjs/common';
import { NowebInMemoryStore } from '@waha/core/engines/noweb/store/NowebInMemoryStore';
import { flipObject, parseBool, splitAt } from '@waha/helpers';
import { PairingCodeResponse } from '@waha/structures/auth.dto';
import {
Channel,
ChannelRole,
CreateChannelRequest,
ListChannelsQuery,
} from '@waha/structures/channels.dto';
import { GetChatsQuery } from '@waha/structures/chats.dto';
import { ContactQuery, ContactRequest } from '@waha/structures/contacts.dto';
import {
@@ -85,6 +93,8 @@ import {
import { IEngineMediaProcessor } from '../../abc/media.abc';
import {
ensureSuffix,
getChannelInviteLink,
isNewsletter,
WAHAInternalEvent,
WhatsappSession,
} from '../../abc/session.abc';
@@ -133,6 +143,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
private startTimeoutId: null | ReturnType<typeof setTimeout> = null;
private autoRestartTimeoutId: null | ReturnType<typeof setTimeout> = null;
private msgRetryCounterCache: NodeCache;
protected engineLogger: BaileysLogger;
get listenConnectionEventsFromTheStart() {
return true;
@@ -151,6 +162,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
stdTTL: 60 * 60, // 1 hour
useClones: false,
});
this.engineLogger = this.loggerBuilder.child({
name: 'NOWEBEngine',
}) as unknown as BaileysLogger;
}
start() {
@@ -169,14 +184,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
auth: {
creds: state.creds,
/** caching makes the store faster to send/recv messages */
keys: makeCacheableSignalKeyStore(
state.keys,
this.logger as unknown as BaileysLogger,
),
keys: makeCacheableSignalKeyStore(state.keys, this.engineLogger),
},
printQRInTerminal: false,
browser: browser,
logger: this.logger,
logger: this.engineLogger,
mobile: false,
defaultQueryTimeoutMs: 120_000,
keepAliveIntervalMs: 30_000,
@@ -863,6 +875,81 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
}
/**
* Channels methods
*/
protected toChannel(newsletter: NewsletterMetadata): Channel {
const role =
// @ts-ignore
newsletter.viewer_metadata?.role ||
(newsletter.viewer_metadata?.view_role as ChannelRole) ||
ChannelRole.GUEST;
const preview = newsletter.preview
? getUrlFromDirectPath(newsletter.preview)
: null;
const picture = newsletter.picture
? getUrlFromDirectPath(newsletter.picture)
: null;
return {
id: newsletter.id,
name: newsletter.name,
description: newsletter.description,
invite: getChannelInviteLink(newsletter.invite),
preview: preview,
picture: picture,
verified: newsletter.verification === 'VERIFIED',
role: role,
};
}
public async channelsList(query: ListChannelsQuery): Promise<Channel[]> {
const newsletters = await this.sock.newsletterSubscribed();
let channels = newsletters.map(this.toChannel);
if (query.role) {
// @ts-ignore
channels = channels.filter((channel) => channel.role === query.role);
}
return channels;
}
public async channelsCreateChannel(request: CreateChannelRequest) {
const newsletter = await this.sock.newsletterCreate(
request.name,
request.description,
);
return this.toChannel(newsletter);
}
public async channelsGetChannel(id: string) {
const newsletter = await this.sock.newsletterMetadata('jid', id);
return this.toChannel(newsletter);
}
public async channelsGetChannelByInviteCode(inviteCode: string) {
const newsletter = await this.sock.newsletterMetadata('invite', inviteCode);
return this.toChannel(newsletter);
}
public async channelsDeleteChannel(id: string) {
return await this.sock.newsletterDelete(id);
}
public async channelsFollowChannel(id: string): Promise<void> {
return await this.sock.newsletterFollow(id);
}
public async channelsUnfollowChannel(id: string): Promise<void> {
return await this.sock.newsletterUnfollow(id);
}
public async channelsMuteChannel(id: string): Promise<void> {
return await this.sock.newsletterMute(id);
}
public async channelsUnmuteChannel(id: string): Promise<void> {
return await this.sock.newsletterUnmute(id);
}
/**
* END - Methods for API
*/
@@ -1222,6 +1309,9 @@ export class EngineMediaProcessor implements IEngineMediaProcessor<any> {
* Convert from 11111111111@s.whatsapp.net to 11111111111@c.us
*/
function toCusFormat(remoteJid) {
if (!remoteJid) {
return remoteJid;
}
if (isJidGroup(remoteJid)) {
return remoteJid;
}
@@ -1231,6 +1321,9 @@ function toCusFormat(remoteJid) {
if (isLidUser(remoteJid)) {
return remoteJid;
}
if (isNewsletter(remoteJid)) {
return remoteJid;
}
if (!remoteJid) {
return;
}
@@ -1252,6 +1345,9 @@ export function toJID(chatId) {
if (isJidStatusBroadcast(chatId)) {
return chatId;
}
if (isNewsletter(chatId)) {
return chatId;
}
const number = chatId.split('@')[0];
return number + '@s.whatsapp.net';
}
+116 -2
View File
@@ -1,6 +1,10 @@
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { IEngineMediaProcessor } from '@waha/core/abc/media.abc';
import { WAHAInternalEvent, WhatsappSession } from '@waha/core/abc/session.abc';
import {
getChannelInviteLink,
WAHAInternalEvent,
WhatsappSession,
} from '@waha/core/abc/session.abc';
import { WebjsClient } from '@waha/core/engines/webjs/WebjsClient';
import {
AvailableInPlusVersion,
@@ -8,6 +12,12 @@ import {
} from '@waha/core/exceptions';
import { QR } from '@waha/core/QR';
import { parseBool } from '@waha/helpers';
import {
Channel,
ChannelRole,
CreateChannelRequest,
ListChannelsQuery,
} from '@waha/structures/channels.dto';
import { GetChatsQuery } from '@waha/structures/chats.dto';
import {
ChatRequest,
@@ -44,6 +54,7 @@ import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import { WAMessageRevokedBody } from '@waha/structures/webhooks.dto';
import {
Channel as WEBJSChannel,
Chat,
ClientOptions,
Contact,
@@ -260,7 +271,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
this.whatsapp.on(Events.READY, () => {
this.status = WAHASessionStatus.WORKING;
this.qr.save('');
this.logger.info(`Session '${this.name}' has been authenticated!`);
this.logger.info(`Session '${this.name}' is ready!`);
});
this.whatsapp.on(Events.AUTHENTICATED, () => {
@@ -617,6 +628,109 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return groupChat.demoteParticipants(participantIds);
}
/**
* Channels methods
*/
protected ChatToChannel(chat: WEBJSChannel): Channel {
// @ts-ignore
const metadata = chat.channelMetadata;
let role = metadata.membershipType.toUpperCase();
if (role === 'VIEWER') {
role = ChannelRole.GUEST;
}
return {
id: chat.id._serialized,
name: chat.name,
description: chat.description,
invite: getChannelInviteLink(metadata.inviteCode),
preview: null,
picture: null,
verified: metadata.verified,
role: role,
};
}
protected ChannelMetadataToChannel(metadata: any): Channel {
let role = metadata.membershipType.toUpperCase();
if (role === 'VIEWER') {
role = ChannelRole.GUEST;
}
return {
id: metadata.id,
name: metadata.titleMetadata.title,
description: metadata.descriptionMetadata.description,
invite: metadata.inviteLink,
preview: metadata.pictureUrl,
picture: metadata.pictureUrl,
verified: metadata.isVerified,
role: role,
};
}
public async channelsList(query: ListChannelsQuery): Promise<Channel[]> {
const data = await this.whatsapp.getChannels();
let channels = data.map(this.ChatToChannel);
if (query.role) {
// @ts-ignore
channels = channels.filter((channel) => channel.role === query.role);
}
const promises = channels.map(async (channel) =>
this.whatsapp.getProfilePicUrl(channel.id),
);
const pictures = await Promise.all(promises);
channels = channels.map((channel, index) => {
channel.picture = pictures[index] || null;
channel.preview = channel.picture;
return channel;
});
return channels;
}
public channelsCreateChannel(
request: CreateChannelRequest,
): Promise<Channel> {
throw new NotImplementedByEngineError();
}
public async channelsGetChannel(id: string): Promise<Channel> {
return await this.channelsGetChannelByInviteCode(id);
}
public async channelsGetChannelByInviteCode(
inviteCode: string,
): Promise<Channel> {
const metadata = await this.whatsapp.getChannelByInviteCode(inviteCode);
const channel = this.ChannelMetadataToChannel(metadata);
channel.preview =
(await this.whatsapp.getProfilePicUrl(channel.id)) || null;
channel.picture = channel.preview;
return channel;
}
public channelsDeleteChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsFollowChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsUnfollowChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsMuteChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public channelsUnmuteChannel(id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
/**
* Presences methods
*/
public async setPresence(presence: WAHAPresenceStatus, chatId?: string) {
let chat: Chat;
switch (presence) {
+94
View File
@@ -0,0 +1,94 @@
import { ApiParam, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
export class CreateChannelRequest {
@ApiProperty({
example: 'Channel Name',
})
name: string;
@ApiProperty({
example: 'Channel Description',
})
description?: string;
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(RemoteFile) },
{ $ref: getSchemaPath(BinaryFile) },
],
})
picture?: RemoteFile | BinaryFile;
}
export enum ChannelRole {
OWNER = 'OWNER',
ADMIN = 'ADMIN',
SUBSCRIBER = 'SUBSCRIBER',
GUEST = 'GUEST',
}
export enum ChannelRoleFilter {
OWNER = ChannelRole.OWNER,
ADMIN = ChannelRole.ADMIN,
SUBSCRIBER = ChannelRole.SUBSCRIBER,
}
export class ListChannelsQuery {
role?: ChannelRoleFilter;
}
export class Channel {
@ApiProperty({
description: 'Newsletter id',
example: '123123123123@newsletter',
})
id: string;
name: string;
description?: string;
@ApiProperty({
description: 'Invite link',
example: 'https://www.whatsapp.com/channel/111111111111111111111111',
})
invite: string;
@ApiProperty({
description: "Preview for channel's picture",
example: 'https://mmg.whatsapp.net/m1/v/t24/An&_nc_cat=10',
})
preview?: string;
@ApiProperty({
description: "Channel's picture",
example: 'https://mmg.whatsapp.net/m1/v/t24/An&_nc_cat=10',
})
picture?: string;
verified: boolean;
role: ChannelRole;
}
export const NewsletterIdApiParam = ApiParam({
name: 'id',
required: true,
type: 'string',
schema: {
default: '123123123@newsletter',
},
description: 'WhatsApp Channel ID',
});
export const NewsletterIdOrInviteCodeApiParam = ApiParam({
name: 'id',
required: true,
type: 'string',
schema: {
default: '123123123@newsletter',
},
description:
'WhatsApp Channel ID or invite code from invite link https://www.whatsapp.com/channel/11111',
});
+1 -1
View File
@@ -33,7 +33,7 @@ export function getEngineName(): string {
}
export const VERSION: WAHAEnvironment = {
version: '2024.7.4',
version: '2024.7.5',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowserExecutablePath(),
+9 -9
View File
@@ -5,9 +5,9 @@ __metadata:
version: 6
cacheKey: 8
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master":
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-channels":
version: 6.7.5
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=7da6b067b37bb2dec388bf43f1517ed98e05ee45"
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=66fde3f3e5df76b52c6a01b75c301d63e2b6a75f"
dependencies:
"@adiwajshing/keyed-db": ^0.2.4
"@hapi/boom": ^9.1.3
@@ -39,7 +39,7 @@ __metadata:
optional: true
sharp:
optional: true
checksum: d16ada049d4d74e08f4be479a5a871d96e75e91f5bf614468bd282475662ab4efa208e82e53339cd28e0f4afbbc3add269a491886ba033e6ba8fe838983fe1b5
checksum: b4f3bc47c559641800e220bddfd06f86848c74dd0c5ea85aba4d1033606a3f90a2d2e488df33da191de75be7357b10df041b2b6d76ee5b7bda1bd37d2586b45c
languageName: node
linkType: hard
@@ -12861,7 +12861,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "waha@workspace:."
dependencies:
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master"
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-channels"
"@adiwajshing/keyed-db": ^0.2.4
"@nestjs/axios": ^3.0.2
"@nestjs/cli": ^9.0.0
@@ -12930,7 +12930,7 @@ __metadata:
tsconfig-paths: ^4.1.0
typescript: ^4.8.4
venom-bot: 5.0.1
whatsapp-web.js: "https://github.com/devlikeapro/whatsapp-web.js#fork-webpack-exodus"
whatsapp-web.js: "github:devlikeapro/whatsapp-web.js#fork-webpack-exodus-channels"
languageName: unknown
linkType: soft
@@ -13027,9 +13027,9 @@ __metadata:
languageName: node
linkType: hard
"whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js#fork-webpack-exodus":
version: 1.23.1-alpha.5
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=72b2a360c1eb5aaa1b7a2d9a2d742d44ba6c0fd5"
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-webpack-exodus-channels":
version: 1.24.0
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=035245d3216ff98f231468d9be87253600194d42"
dependencies:
"@pedroslopez/moduleraid": ^5.0.2
adm-zip: ^0.5.10
@@ -13048,7 +13048,7 @@ __metadata:
optional: true
unzipper:
optional: true
checksum: 6cdd9fb648e2523aea5bca21e10a4133db9faf3b64d96d20fec381275b48cac3729115db442bab20591f7f65b163ae853d569a4fb6c9266db9f388eb1a5a3527
checksum: 32048fdf11284ca516e2a09801f74ba1e5676cbdfbe4ac7aee7b167e26173aa250cd2593288573e490b474b3c5ebdc20933bd57b3b0b42c1c9299d9fda563923
languageName: node
linkType: hard