feat: Webhook (#8457)

* feat: introduce webhook

* wip

* wip

* wip

* Update CHANGELOG.md
This commit is contained in:
syuilo 2022-04-02 15:28:49 +09:00 committed by GitHub
parent d359b2c387
commit 8b2a0a5773
28 changed files with 815 additions and 10 deletions

View File

@ -13,6 +13,7 @@ You should also include the user name that made the change.
## 12.x.x (unreleased) ## 12.x.x (unreleased)
### Improvements ### Improvements
- Webhooks @syuilo
- Bull Dashboardを組み込み、ジョブキューの確認や操作を行えるように @syuilo - Bull Dashboardを組み込み、ジョブキューの確認や操作を行えるように @syuilo
- Bull Dashboardを開くには、最初だけ一旦ログアウトしてから再度管理者権限を持つアカウントでログインする必要があります - Bull Dashboardを開くには、最初だけ一旦ログアウトしてから再度管理者権限を持つアカウントでログインする必要があります
- Check that installed Node.js version fulfills version requirement @ThatOneCalculator - Check that installed Node.js version fulfills version requirement @ThatOneCalculator

View File

@ -0,0 +1,19 @@
export class webhook1648548247382 {
name = 'webhook1648548247382'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "webhook" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "on" character varying(128) array NOT NULL DEFAULT '{}', "url" character varying(1024) NOT NULL, "secret" character varying(1024) NOT NULL, "active" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_e6765510c2d078db49632b59020" PRIMARY KEY ("id")); COMMENT ON COLUMN "webhook"."createdAt" IS 'The created date of the Antenna.'; COMMENT ON COLUMN "webhook"."userId" IS 'The owner ID.'; COMMENT ON COLUMN "webhook"."name" IS 'The name of the Antenna.'`);
await queryRunner.query(`CREATE INDEX "IDX_f272c8c8805969e6a6449c77b3" ON "webhook" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_8063a0586ed1dfbe86e982d961" ON "webhook" ("on") `);
await queryRunner.query(`CREATE INDEX "IDX_5a056076f76b2efe08216ba655" ON "webhook" ("active") `);
await queryRunner.query(`ALTER TABLE "webhook" ADD CONSTRAINT "FK_f272c8c8805969e6a6449c77b3c" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "webhook" DROP CONSTRAINT "FK_f272c8c8805969e6a6449c77b3c"`);
await queryRunner.query(`DROP INDEX "public"."IDX_5a056076f76b2efe08216ba655"`);
await queryRunner.query(`DROP INDEX "public"."IDX_8063a0586ed1dfbe86e982d961"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f272c8c8805969e6a6449c77b3"`);
await queryRunner.query(`DROP TABLE "webhook"`);
}
}

View File

@ -0,0 +1,14 @@
export class webhook21648816172177 {
name = 'webhook21648816172177'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "webhook" ADD "latestSentAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "webhook" ADD "latestStatus" integer`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "webhook" DROP COLUMN "latestStatus"`);
await queryRunner.query(`ALTER TABLE "webhook" DROP COLUMN "latestSentAt"`);
}
}

View File

@ -73,6 +73,7 @@ import { PasswordResetRequest } from '@/models/entities/password-reset-request.j
import { UserPending } from '@/models/entities/user-pending.js'; import { UserPending } from '@/models/entities/user-pending.js';
import { entities as charts } from '@/services/chart/entities.js'; import { entities as charts } from '@/services/chart/entities.js';
import { Webhook } from '@/models/entities/webhook.js';
const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false);
@ -171,6 +172,7 @@ export const entities = [
Ad, Ad,
PasswordResetRequest, PasswordResetRequest,
UserPending, UserPending,
Webhook,
...charts, ...charts,
]; ];

View File

@ -0,0 +1,49 @@
import { Webhooks } from '@/models/index.js';
import { Webhook } from '@/models/entities/webhook.js';
import { subsdcriber } from '../db/redis.js';
let webhooksFetched = false;
let webhooks: Webhook[] = [];
export async function getActiveWebhooks() {
if (!webhooksFetched) {
webhooks = await Webhooks.findBy({
active: true,
});
webhooksFetched = true;
}
return webhooks;
}
subsdcriber.on('message', async (_, data) => {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message;
switch (type) {
case 'webhookCreated':
if (body.active) {
webhooks.push(body);
}
break;
case 'webhookUpdated':
if (body.active) {
const i = webhooks.findIndex(a => a.id === body.id);
if (i > -1) {
webhooks[i] = body;
} else {
webhooks.push(body);
}
} else {
webhooks = webhooks.filter(a => a.id !== body.id);
}
break;
case 'webhookDeleted':
webhooks = webhooks.filter(a => a.id !== body.id);
break;
default:
break;
}
}
});

View File

@ -0,0 +1,73 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { User } from './user.js';
import { id } from '../id.js';
export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const;
@Entity()
export class Webhook {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
comment: 'The created date of the Antenna.',
})
public createdAt: Date;
@Index()
@Column({
...id(),
comment: 'The owner ID.',
})
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: User | null;
@Column('varchar', {
length: 128,
comment: 'The name of the Antenna.',
})
public name: string;
@Index()
@Column('varchar', {
length: 128, array: true, default: '{}',
})
public on: (typeof webhookEventTypes)[number][];
@Column('varchar', {
length: 1024,
})
public url: string;
@Column('varchar', {
length: 1024,
})
public secret: string;
@Index()
@Column('boolean', {
default: true,
})
public active: boolean;
/**
*
*/
@Column('timestamp with time zone', {
nullable: true,
})
public latestSentAt: Date | null;
/**
* HTTPステータスコード
*/
@Column('integer', {
nullable: true,
})
public latestStatus: number | null;
}

View File

@ -64,6 +64,7 @@ import { Ad } from './entities/ad.js';
import { PasswordResetRequest } from './entities/password-reset-request.js'; import { PasswordResetRequest } from './entities/password-reset-request.js';
import { UserPending } from './entities/user-pending.js'; import { UserPending } from './entities/user-pending.js';
import { InstanceRepository } from './repositories/instance.js'; import { InstanceRepository } from './repositories/instance.js';
import { Webhook } from './entities/webhook.js';
export const Announcements = db.getRepository(Announcement); export const Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead); export const AnnouncementReads = db.getRepository(AnnouncementRead);
@ -125,5 +126,6 @@ export const Channels = (ChannelRepository);
export const ChannelFollowings = db.getRepository(ChannelFollowing); export const ChannelFollowings = db.getRepository(ChannelFollowing);
export const ChannelNotePinings = db.getRepository(ChannelNotePining); export const ChannelNotePinings = db.getRepository(ChannelNotePining);
export const RegistryItems = db.getRepository(RegistryItem); export const RegistryItems = db.getRepository(RegistryItem);
export const Webhooks = db.getRepository(Webhook);
export const Ads = db.getRepository(Ad); export const Ads = db.getRepository(Ad);
export const PasswordResetRequests = db.getRepository(PasswordResetRequest); export const PasswordResetRequests = db.getRepository(PasswordResetRequest);

View File

@ -8,13 +8,15 @@ import processInbox from './processors/inbox.js';
import processDb from './processors/db/index.js'; import processDb from './processors/db/index.js';
import processObjectStorage from './processors/object-storage/index.js'; import processObjectStorage from './processors/object-storage/index.js';
import processSystemQueue from './processors/system/index.js'; import processSystemQueue from './processors/system/index.js';
import processWebhookDeliver from './processors/webhook-deliver.js';
import { endedPollNotification } from './processors/ended-poll-notification.js'; import { endedPollNotification } from './processors/ended-poll-notification.js';
import { queueLogger } from './logger.js'; import { queueLogger } from './logger.js';
import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFile } from '@/models/entities/drive-file.js';
import { getJobInfo } from './get-job-info.js'; import { getJobInfo } from './get-job-info.js';
import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue } from './queues.js'; import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue, webhookDeliverQueue } from './queues.js';
import { ThinUser } from './types.js'; import { ThinUser } from './types.js';
import { IActivity } from '@/remote/activitypub/type.js'; import { IActivity } from '@/remote/activitypub/type.js';
import { Webhook } from '@/models/entities/webhook.js';
function renderError(e: Error): any { function renderError(e: Error): any {
return { return {
@ -26,6 +28,7 @@ function renderError(e: Error): any {
const systemLogger = queueLogger.createSubLogger('system'); const systemLogger = queueLogger.createSubLogger('system');
const deliverLogger = queueLogger.createSubLogger('deliver'); const deliverLogger = queueLogger.createSubLogger('deliver');
const webhookLogger = queueLogger.createSubLogger('webhook');
const inboxLogger = queueLogger.createSubLogger('inbox'); const inboxLogger = queueLogger.createSubLogger('inbox');
const dbLogger = queueLogger.createSubLogger('db'); const dbLogger = queueLogger.createSubLogger('db');
const objectStorageLogger = queueLogger.createSubLogger('objectStorage'); const objectStorageLogger = queueLogger.createSubLogger('objectStorage');
@ -70,6 +73,14 @@ objectStorageQueue
.on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`));
webhookDeliverQueue
.on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`))
.on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
export function deliver(user: ThinUser, content: unknown, to: string | null) { export function deliver(user: ThinUser, content: unknown, to: string | null) {
if (content == null) return null; if (content == null) return null;
if (to == null) return null; if (to == null) return null;
@ -251,12 +262,32 @@ export function createCleanRemoteFilesJob() {
}); });
} }
export function webhookDeliver(webhook: Webhook, content: unknown) {
const data = {
content,
webhookId: webhook.id,
to: webhook.url,
secret: webhook.secret,
};
return webhookDeliverQueue.add(data, {
attempts: 4,
timeout: 1 * 60 * 1000, // 1min
backoff: {
type: 'apBackoff',
},
removeOnComplete: true,
removeOnFail: true,
});
}
export default function() { export default function() {
if (envOption.onlyServer) return; if (envOption.onlyServer) return;
deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver);
inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); inboxQueue.process(config.inboxJobConcurrency || 16, processInbox);
endedPollNotificationQueue.process(endedPollNotification); endedPollNotificationQueue.process(endedPollNotification);
webhookDeliverQueue.process(64, processWebhookDeliver);
processDb(dbQueue); processDb(dbQueue);
processObjectStorage(objectStorageQueue); processObjectStorage(objectStorageQueue);

View File

@ -0,0 +1,56 @@
import { URL } from 'node:url';
import Bull from 'bull';
import Logger from '@/services/logger.js';
import { WebhookDeliverJobData } from '../types.js';
import { getResponse, StatusError } from '@/misc/fetch.js';
import { Webhooks } from '@/models/index.js';
import config from '@/config/index.js';
const logger = new Logger('webhook');
let latest: string | null = null;
export default async (job: Bull.Job<WebhookDeliverJobData>) => {
try {
if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) {
logger.debug(`delivering ${latest}`);
}
const res = await getResponse({
url: job.data.to,
method: 'POST',
headers: {
'User-Agent': 'Misskey-Hooks',
'X-Misskey-Host': config.host,
'X-Misskey-Hook-Id': job.data.webhookId,
'X-Misskey-Hook-Secret': job.data.secret,
},
body: JSON.stringify(job.data.content),
});
Webhooks.update({ id: job.data.webhookId }, {
latestSentAt: new Date(),
latestStatus: res.status,
});
return 'Success';
} catch (res) {
Webhooks.update({ id: job.data.webhookId }, {
latestSentAt: new Date(),
latestStatus: res instanceof StatusError ? res.statusCode : 1,
});
if (res instanceof StatusError) {
// 4xx
if (res.isClientError) {
return `${res.statusCode} ${res.statusMessage}`;
}
// 5xx etc.
throw `${res.statusCode} ${res.statusMessage}`;
} else {
// DNS error, socket error, timeout ...
throw res;
}
}
};

View File

@ -1,6 +1,6 @@
import config from '@/config/index.js'; import config from '@/config/index.js';
import { initialize as initializeQueue } from './initialize.js'; import { initialize as initializeQueue } from './initialize.js';
import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData } from './types.js'; import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from './types.js';
export const systemQueue = initializeQueue<Record<string, unknown>>('system'); export const systemQueue = initializeQueue<Record<string, unknown>>('system');
export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification'); export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification');
@ -8,6 +8,7 @@ export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.de
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16); export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16);
export const dbQueue = initializeQueue<DbJobData>('db'); export const dbQueue = initializeQueue<DbJobData>('db');
export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage'); export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage');
export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>('webhookDeliver', 64);
export const queues = [ export const queues = [
systemQueue, systemQueue,
@ -16,4 +17,5 @@ export const queues = [
inboxQueue, inboxQueue,
dbQueue, dbQueue,
objectStorageQueue, objectStorageQueue,
webhookDeliverQueue,
]; ];

View File

@ -1,6 +1,7 @@
import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFile } from '@/models/entities/drive-file.js';
import { Note } from '@/models/entities/note'; import { Note } from '@/models/entities/note';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { Webhook } from '@/models/entities/webhook';
import { IActivity } from '@/remote/activitypub/type.js'; import { IActivity } from '@/remote/activitypub/type.js';
import httpSignature from 'http-signature'; import httpSignature from 'http-signature';
@ -46,6 +47,13 @@ export type EndedPollNotificationJobData = {
noteId: Note['id']; noteId: Note['id'];
}; };
export type WebhookDeliverJobData = {
content: unknown;
webhookId: Webhook['id'];
to: string;
secret: string;
};
export type ThinUser = { export type ThinUser = {
id: User['id']; id: User['id'];
}; };

View File

@ -202,6 +202,11 @@ import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js'; import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_userGroupInvites from './endpoints/i/user-group-invites.js'; import * as ep___i_userGroupInvites from './endpoints/i/user-group-invites.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
import * as ep___messaging_history from './endpoints/messaging/history.js'; import * as ep___messaging_history from './endpoints/messaging/history.js';
import * as ep___messaging_messages from './endpoints/messaging/messages.js'; import * as ep___messaging_messages from './endpoints/messaging/messages.js';
import * as ep___messaging_messages_create from './endpoints/messaging/messages/create.js'; import * as ep___messaging_messages_create from './endpoints/messaging/messages/create.js';
@ -507,6 +512,11 @@ const eps = [
['i/update-email', ep___i_updateEmail], ['i/update-email', ep___i_updateEmail],
['i/update', ep___i_update], ['i/update', ep___i_update],
['i/user-group-invites', ep___i_userGroupInvites], ['i/user-group-invites', ep___i_userGroupInvites],
['i/webhooks/create', ep___i_webhooks_create],
['i/webhooks/list', ep___i_webhooks_list],
['i/webhooks/show', ep___i_webhooks_show],
['i/webhooks/update', ep___i_webhooks_update],
['i/webhooks/delete', ep___i_webhooks_delete],
['messaging/history', ep___messaging_history], ['messaging/history', ep___messaging_history],
['messaging/messages', ep___messaging_messages], ['messaging/messages', ep___messaging_messages],
['messaging/messages/create', ep___messaging_messages_create], ['messaging/messages/create', ep___messaging_messages_create],

View File

@ -0,0 +1,43 @@
import define from '../../../define.js';
import { genId } from '@/misc/gen-id.js';
import { Webhooks } from '@/models/index.js';
import { publishInternalEvent } from '@/services/stream.js';
import { webhookEventTypes } from '@/models/entities/webhook.js';
export const meta = {
tags: ['webhooks'],
requireCredential: true,
kind: 'write:account',
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
url: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', minLength: 1, maxLength: 1024 },
on: { type: 'array', items: {
type: 'string', enum: webhookEventTypes,
} },
},
required: ['name', 'url', 'secret', 'on'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const webhook = await Webhooks.insert({
id: genId(),
createdAt: new Date(),
userId: user.id,
name: ps.name,
url: ps.url,
secret: ps.secret,
on: ps.on,
}).then(x => Webhooks.findOneByOrFail(x.identifiers[0]));
publishInternalEvent('webhookCreated', webhook);
return webhook;
});

View File

@ -0,0 +1,44 @@
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
import { Webhooks } from '@/models/index.js';
import { publishInternalEvent } from '@/services/stream.js';
export const meta = {
tags: ['webhooks'],
requireCredential: true,
kind: 'write:account',
errors: {
noSuchWebhook: {
message: 'No such webhook.',
code: 'NO_SUCH_WEBHOOK',
id: 'bae73e5a-5522-4965-ae19-3a8688e71d82',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
webhookId: { type: 'string', format: 'misskey:id' },
},
required: ['webhookId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const webhook = await Webhooks.findOneBy({
id: ps.webhookId,
userId: user.id,
});
if (webhook == null) {
throw new ApiError(meta.errors.noSuchWebhook);
}
await Webhooks.delete(webhook.id);
publishInternalEvent('webhookDeleted', webhook);
});

View File

@ -0,0 +1,25 @@
import define from '../../../define.js';
import { Webhooks } from '@/models/index.js';
export const meta = {
tags: ['webhooks', 'account'],
requireCredential: true,
kind: 'read:account',
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const webhooks = await Webhooks.findBy({
userId: me.id,
});
return webhooks;
});

View File

@ -0,0 +1,41 @@
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
import { Webhooks } from '@/models/index.js';
export const meta = {
tags: ['webhooks'],
requireCredential: true,
kind: 'read:account',
errors: {
noSuchWebhook: {
message: 'No such webhook.',
code: 'NO_SUCH_WEBHOOK',
id: '50f614d9-3047-4f7e-90d8-ad6b2d5fb098',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
webhookId: { type: 'string', format: 'misskey:id' },
},
required: ['webhookId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const webhook = await Webhooks.findOneBy({
id: ps.webhookId,
userId: user.id,
});
if (webhook == null) {
throw new ApiError(meta.errors.noSuchWebhook);
}
return webhook;
});

View File

@ -0,0 +1,59 @@
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
import { Webhooks } from '@/models/index.js';
import { publishInternalEvent } from '@/services/stream.js';
import { webhookEventTypes } from '@/models/entities/webhook.js';
export const meta = {
tags: ['webhooks'],
requireCredential: true,
kind: 'write:account',
errors: {
noSuchWebhook: {
message: 'No such webhook.',
code: 'NO_SUCH_WEBHOOK',
id: 'fb0fea69-da18-45b1-828d-bd4fd1612518',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
webhookId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 },
url: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', minLength: 1, maxLength: 1024 },
on: { type: 'array', items: {
type: 'string', enum: webhookEventTypes,
} },
active: { type: 'boolean' },
},
required: ['webhookId', 'name', 'url', 'secret', 'on', 'active'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const webhook = await Webhooks.findOneBy({
id: ps.webhookId,
userId: user.id,
});
if (webhook == null) {
throw new ApiError(meta.errors.noSuchWebhook);
}
await Webhooks.update(webhook.id, {
name: ps.name,
url: ps.url,
secret: ps.secret,
on: ps.on,
active: ps.active,
});
publishInternalEvent('webhookUpdated', webhook);
});

View File

@ -15,6 +15,7 @@ import { AbuseUserReport } from '@/models/entities/abuse-user-report.js';
import { Signin } from '@/models/entities/signin.js'; import { Signin } from '@/models/entities/signin.js';
import { Page } from '@/models/entities/page.js'; import { Page } from '@/models/entities/page.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
import { Webhook } from '@/models/entities/webhook';
//#region Stream type-body definitions //#region Stream type-body definitions
export interface InternalStreamTypes { export interface InternalStreamTypes {
@ -23,6 +24,9 @@ export interface InternalStreamTypes {
userChangeModeratorState: { id: User['id']; isModerator: User['isModerator']; }; userChangeModeratorState: { id: User['id']; isModerator: User['isModerator']; };
userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; };
remoteUserUpdated: { id: User['id']; }; remoteUserUpdated: { id: User['id']; };
webhookCreated: Webhook;
webhookDeleted: Webhook;
webhookUpdated: Webhook;
antennaCreated: Antenna; antennaCreated: Antenna;
antennaDeleted: Antenna; antennaDeleted: Antenna;
antennaUpdated: Antenna; antennaUpdated: Antenna;

View File

@ -10,6 +10,8 @@ import { Blockings, Users, FollowRequests, Followings, UserListJoinings, UserLis
import { perUserFollowingChart } from '@/services/chart/index.js'; import { perUserFollowingChart } from '@/services/chart/index.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { webhookDeliver } from '@/queue/index.js';
export default async function(blocker: User, blockee: User) { export default async function(blocker: User, blockee: User) {
await Promise.all([ await Promise.all([
@ -57,9 +59,17 @@ async function cancelRequest(follower: User, followee: User) {
if (Users.isLocalUser(follower)) { if (Users.isLocalUser(follower)) {
Users.pack(followee, follower, { Users.pack(followee, follower, {
detail: true, detail: true,
}).then(packed => { }).then(async packed => {
publishUserEvent(follower.id, 'unfollow', packed); publishUserEvent(follower.id, 'unfollow', packed);
publishMainStream(follower.id, 'unfollow', packed); publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'unfollow',
user: packed,
});
}
}); });
} }
@ -102,9 +112,17 @@ async function unFollow(follower: User, followee: User) {
if (Users.isLocalUser(follower)) { if (Users.isLocalUser(follower)) {
Users.pack(followee, follower, { Users.pack(followee, follower, {
detail: true, detail: true,
}).then(packed => { }).then(async packed => {
publishUserEvent(follower.id, 'unfollow', packed); publishUserEvent(follower.id, 'unfollow', packed);
publishMainStream(follower.id, 'unfollow', packed); publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'unfollow',
user: packed,
});
}
}); });
} }

View File

@ -15,6 +15,8 @@ import { genId } from '@/misc/gen-id.js';
import { createNotification } from '../create-notification.js'; import { createNotification } from '../create-notification.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { Packed } from '@/misc/schema.js'; import { Packed } from '@/misc/schema.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
import { webhookDeliver } from '@/queue/index.js';
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -89,15 +91,33 @@ export async function insertFollowingDoc(followee: { id: User['id']; host: User[
if (Users.isLocalUser(follower)) { if (Users.isLocalUser(follower)) {
Users.pack(followee.id, follower, { Users.pack(followee.id, follower, {
detail: true, detail: true,
}).then(packed => { }).then(async packed => {
publishUserEvent(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); publishUserEvent(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">);
publishMainStream(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); publishMainStream(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'follow',
user: packed,
});
}
}); });
} }
// Publish followed event // Publish followed event
if (Users.isLocalUser(followee)) { if (Users.isLocalUser(followee)) {
Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'followed', packed)); Users.pack(follower.id, followee).then(async packed => {
publishMainStream(followee.id, 'followed', packed)
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'followed',
user: packed,
});
}
});
// 通知を作成 // 通知を作成
createNotification(followee.id, 'follow', { createNotification(followee.id, 'follow', {

View File

@ -3,12 +3,13 @@ import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderFollow from '@/remote/activitypub/renderer/follow.js'; import renderFollow from '@/remote/activitypub/renderer/follow.js';
import renderUndo from '@/remote/activitypub/renderer/undo.js'; import renderUndo from '@/remote/activitypub/renderer/undo.js';
import renderReject from '@/remote/activitypub/renderer/reject.js'; import renderReject from '@/remote/activitypub/renderer/reject.js';
import { deliver } from '@/queue/index.js'; import { deliver, webhookDeliver } from '@/queue/index.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { Followings, Users, Instances } from '@/models/index.js'; import { Followings, Users, Instances } from '@/models/index.js';
import { instanceChart, perUserFollowingChart } from '@/services/chart/index.js'; import { instanceChart, perUserFollowingChart } from '@/services/chart/index.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
const logger = new Logger('following/delete'); const logger = new Logger('following/delete');
@ -31,9 +32,17 @@ export default async function(follower: { id: User['id']; host: User['host']; ur
if (!silent && Users.isLocalUser(follower)) { if (!silent && Users.isLocalUser(follower)) {
Users.pack(followee.id, follower, { Users.pack(followee.id, follower, {
detail: true, detail: true,
}).then(packed => { }).then(async packed => {
publishUserEvent(follower.id, 'unfollow', packed); publishUserEvent(follower.id, 'unfollow', packed);
publishMainStream(follower.id, 'unfollow', packed); publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'unfollow',
user: packed,
});
}
}); });
} }

View File

@ -1,11 +1,12 @@
import { renderActivity } from '@/remote/activitypub/renderer/index.js'; import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderFollow from '@/remote/activitypub/renderer/follow.js'; import renderFollow from '@/remote/activitypub/renderer/follow.js';
import renderReject from '@/remote/activitypub/renderer/reject.js'; import renderReject from '@/remote/activitypub/renderer/reject.js';
import { deliver } from '@/queue/index.js'; import { deliver, webhookDeliver } from '@/queue/index.js';
import { publishMainStream, publishUserEvent } from '@/services/stream.js'; import { publishMainStream, publishUserEvent } from '@/services/stream.js';
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js';
import { Users, FollowRequests, Followings } from '@/models/index.js'; import { Users, FollowRequests, Followings } from '@/models/index.js';
import { decrementFollowing } from './delete.js'; import { decrementFollowing } from './delete.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
type Local = ILocalUser | { type Local = ILocalUser | {
id: ILocalUser['id']; id: ILocalUser['id'];
@ -111,4 +112,12 @@ async function publishUnfollow(followee: Both, follower: Local) {
publishUserEvent(follower.id, 'unfollow', packedFollowee); publishUserEvent(follower.id, 'unfollow', packedFollowee);
publishMainStream(follower.id, 'unfollow', packedFollowee); publishMainStream(follower.id, 'unfollow', packedFollowee);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'unfollow',
user: packedFollowee,
});
}
} }

View File

@ -35,9 +35,11 @@ import { Channel } from '@/models/entities/channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { getAntennas } from '@/misc/antenna-cache.js'; import { getAntennas } from '@/misc/antenna-cache.js';
import { endedPollNotificationQueue } from '@/queue/queues.js'; import { endedPollNotificationQueue } from '@/queue/queues.js';
import { webhookDeliver } from '@/queue/index.js';
import { Cache } from '@/misc/cache.js'; import { Cache } from '@/misc/cache.js';
import { UserProfile } from '@/models/entities/user-profile.js'; import { UserProfile } from '@/models/entities/user-profile.js';
import { db } from '@/db/postgre.js'; import { db } from '@/db/postgre.js';
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
@ -345,6 +347,16 @@ export default async (user: { id: User['id']; username: User['username']; host:
publishNotesStream(noteObj); publishNotesStream(noteObj);
getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'note',
note: noteObj,
});
}
});
const nm = new NotificationManager(user, note); const nm = new NotificationManager(user, note);
const nmRelatedPromises = []; const nmRelatedPromises = [];
@ -365,6 +377,14 @@ export default async (user: { id: User['id']; username: User['username']; host:
if (!threadMuted) { if (!threadMuted) {
nm.push(data.reply.userId, 'reply'); nm.push(data.reply.userId, 'reply');
publishMainStream(data.reply.userId, 'reply', noteObj); publishMainStream(data.reply.userId, 'reply', noteObj);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'reply',
note: noteObj,
});
}
} }
} }
} }
@ -384,6 +404,14 @@ export default async (user: { id: User['id']; username: User['username']; host:
// Publish event // Publish event
if ((user.id !== data.renote.userId) && data.renote.userHost === null) { if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
publishMainStream(data.renote.userId, 'renote', noteObj); publishMainStream(data.renote.userId, 'renote', noteObj);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'renote',
note: noteObj,
});
}
} }
} }
@ -620,6 +648,14 @@ async function createMentionedEvents(mentionedUsers: MinimumUser[], note: Note,
publishMainStream(u.id, 'mention', detailPackedNote); publishMainStream(u.id, 'mention', detailPackedNote);
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
for (const webhook of webhooks) {
webhookDeliver(webhook, {
type: 'mention',
note: detailPackedNote,
});
}
// Create notification // Create notification
nm.push(u.id, 'mention'); nm.push(u.id, 'mention');
} }

View File

@ -80,7 +80,7 @@ export default defineComponent({
margin-right: 0.75em; margin-right: 0.75em;
flex-shrink: 0; flex-shrink: 0;
text-align: center; text-align: center;
opacity: 0.8; color: var(--fgTransparentWeak);
&:empty { &:empty {
display: none; display: none;

View File

@ -148,6 +148,11 @@ const menuDef = computed(() => [{
text: 'API', text: 'API',
to: '/settings/api', to: '/settings/api',
active: page.value === 'api', active: page.value === 'api',
}, {
icon: 'fas fa-bolt',
text: 'Webhook',
to: '/settings/webhook',
active: page.value === 'webhook',
}, { }, {
icon: 'fas fa-ellipsis-h', icon: 'fas fa-ellipsis-h',
text: i18n.ts.other, text: i18n.ts.other,
@ -192,6 +197,9 @@ const component = computed(() => {
case 'security': return defineAsyncComponent(() => import('./security.vue')); case 'security': return defineAsyncComponent(() => import('./security.vue'));
case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));
case 'api': return defineAsyncComponent(() => import('./api.vue')); case 'api': return defineAsyncComponent(() => import('./api.vue'));
case 'webhook': return defineAsyncComponent(() => import('./webhook.vue'));
case 'webhook/new': return defineAsyncComponent(() => import('./webhook.new.vue'));
case 'webhook/edit': return defineAsyncComponent(() => import('./webhook.edit.vue'));
case 'apps': return defineAsyncComponent(() => import('./apps.vue')); case 'apps': return defineAsyncComponent(() => import('./apps.vue'));
case 'other': return defineAsyncComponent(() => import('./other.vue')); case 'other': return defineAsyncComponent(() => import('./other.vue'));
case 'general': return defineAsyncComponent(() => import('./general.vue')); case 'general': return defineAsyncComponent(() => import('./general.vue'));

View File

@ -0,0 +1,89 @@
<template>
<div class="_formRoot">
<FormInput v-model="name" class="_formBlock">
<template #label>Name</template>
</FormInput>
<FormInput v-model="url" type="url" class="_formBlock">
<template #label>URL</template>
</FormInput>
<FormInput v-model="secret" class="_formBlock">
<template #prefix><i class="fas fa-lock"></i></template>
<template #label>Secret</template>
</FormInput>
<FormSection>
<template #label>Events</template>
<FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
<FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
<FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
<FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
<FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
<FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
<FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
</FormSection>
<FormSwitch v-model="active" class="_formBlock">Active</FormSwitch>
<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<FormButton primary inline @click="save"><i class="fas fa-check"></i> {{ i18n.ts.save }}</FormButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import FormInput from '@/components/form/input.vue';
import FormSection from '@/components/form/section.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
const webhook = await os.api('i/webhooks/show', {
webhookId: new URLSearchParams(window.location.search).get('id')
});
let name = $ref(webhook.name);
let url = $ref(webhook.url);
let secret = $ref(webhook.secret);
let active = $ref(webhook.active);
let event_follow = $ref(webhook.on.includes('follow'));
let event_followed = $ref(webhook.on.includes('followed'));
let event_note = $ref(webhook.on.includes('note'));
let event_reply = $ref(webhook.on.includes('reply'));
let event_renote = $ref(webhook.on.includes('renote'));
let event_reaction = $ref(webhook.on.includes('reaction'));
let event_mention = $ref(webhook.on.includes('mention'));
async function save(): Promise<void> {
const events = [];
if (event_follow) events.push('follow');
if (event_followed) events.push('followed');
if (event_note) events.push('note');
if (event_reply) events.push('reply');
if (event_renote) events.push('renote');
if (event_reaction) events.push('reaction');
if (event_mention) events.push('mention');
os.apiWithDialog('i/webhooks/update', {
name,
url,
secret,
on: events,
active,
});
}
defineExpose({
[symbols.PAGE_INFO]: {
title: 'Edit webhook',
icon: 'fas fa-bolt',
bg: 'var(--bg)',
},
});
</script>

View File

@ -0,0 +1,81 @@
<template>
<div class="_formRoot">
<FormInput v-model="name" class="_formBlock">
<template #label>Name</template>
</FormInput>
<FormInput v-model="url" type="url" class="_formBlock">
<template #label>URL</template>
</FormInput>
<FormInput v-model="secret" class="_formBlock">
<template #prefix><i class="fas fa-lock"></i></template>
<template #label>Secret</template>
</FormInput>
<FormSection>
<template #label>Events</template>
<FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
<FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
<FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
<FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
<FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
<FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
<FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
</FormSection>
<div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<FormButton primary inline @click="create"><i class="fas fa-check"></i> {{ i18n.ts.create }}</FormButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import FormInput from '@/components/form/input.vue';
import FormSection from '@/components/form/section.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
let name = $ref('');
let url = $ref('');
let secret = $ref('');
let event_follow = $ref(true);
let event_followed = $ref(true);
let event_note = $ref(true);
let event_reply = $ref(true);
let event_renote = $ref(true);
let event_reaction = $ref(true);
let event_mention = $ref(true);
async function create(): Promise<void> {
const events = [];
if (event_follow) events.push('follow');
if (event_followed) events.push('followed');
if (event_note) events.push('note');
if (event_reply) events.push('reply');
if (event_renote) events.push('renote');
if (event_reaction) events.push('reaction');
if (event_mention) events.push('mention');
os.apiWithDialog('i/webhooks/create', {
name,
url,
secret,
on: events,
});
}
defineExpose({
[symbols.PAGE_INFO]: {
title: 'Create new webhook',
icon: 'fas fa-bolt',
bg: 'var(--bg)',
},
});
</script>

View File

@ -0,0 +1,52 @@
<template>
<div class="_formRoot">
<FormSection>
<FormLink :to="`/settings/webhook/new`">
Create webhook
</FormLink>
</FormSection>
<FormSection>
<MkPagination :pagination="pagination">
<template v-slot="{items}">
<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit?id=${webhook.id}`" class="_formBlock">
<template #icon>
<i v-if="webhook.active === false" class="fas fa-circle-pause"></i>
<i v-else-if="webhook.latestStatus === null" class="far fa-circle"></i>
<i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="fas fa-check" :style="{ color: 'var(--success)' }"></i>
<i v-else class="fas fa-triangle-exclamation" :style="{ color: 'var(--error)' }"></i>
</template>
{{ webhook.name || webhook.url }}
<template #suffix>
<MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
</template>
</FormLink>
</template>
</MkPagination>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkPagination from '@/components/ui/pagination.vue';
import FormSection from '@/components/form/section.vue';
import FormLink from '@/components/form/link.vue';
import { userPage } from '@/filters/user';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
const pagination = {
endpoint: 'i/webhooks/list' as const,
limit: 10,
};
defineExpose({
[symbols.PAGE_INFO]: {
title: 'Webhook',
icon: 'fas fa-bolt',
bg: 'var(--bg)',
},
});
</script>