Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0a136a1
feat
gongfuture Apr 27, 2026
824359c
Merge commit '1cab3ac75c30283f9ca7dbb725e43debd8bf90be' into feat/not…
gongfuture Apr 27, 2026
530c4b3
fix issues
gongfuture Apr 27, 2026
6240ca6
fix backend isues
gongfuture Apr 27, 2026
85a1bc8
add to credit
gongfuture Apr 27, 2026
047bc4c
fallback config == null检查
gongfuture Apr 27, 2026
b21a590
fix
gongfuture Apr 27, 2026
f99b105
fix url encode issue
gongfuture Apr 27, 2026
3681487
fix
gongfuture Apr 27, 2026
48f1d04
这怎么多个分号
gongfuture Apr 27, 2026
bdeaff2
perf
gongfuture Apr 28, 2026
60b1f49
get不显示body_template
gongfuture Apr 28, 2026
83bb4cb
大小写统一
gongfuture Apr 28, 2026
636b6c2
换成灰色
gongfuture May 9, 2026
f126a6d
自定义请求头添加按钮修改
gongfuture May 9, 2026
a6cd80e
多项refactor
gongfuture May 12, 2026
1d86506
格式修复
gongfuture May 12, 2026
7b3a099
feat: 增加level参数支持
gongfuture May 14, 2026
dd94aa0
small fix
gongfuture May 14, 2026
eaaa274
Merge commit 'ee60707af7b8355d3a93c1781be65f93e4437161' into feat/not…
gongfuture Jul 1, 2026
b83e86d
修正header问题
gongfuture Jul 10, 2026
0be65d2
lint
gongfuture Jul 10, 2026
c49fd3c
Merge remote-tracking branch 'upstream/dev' into feat/notification/we…
gongfuture Jul 10, 2026
38e8fe9
调整了一下顺序
gongfuture Jul 10, 2026
2f1bd83
ntfy 图标网址常量化
gongfuture Jul 10, 2026
2cf895a
过长表单的修复一不小心被回退了,重新加回去
gongfuture Jul 10, 2026
2b33c43
lint
gongfuture Jul 11, 2026
5986af1
Merge remote-tracking branch 'upstream/dev' into feat/notification/we…
gongfuture Jul 20, 2026
b888bd7
清理过时翻译字段
gongfuture Aug 3, 2026
19ff85f
修正校验
gongfuture Aug 5, 2026
4c80d82
rfc 8187 编码
gongfuture Aug 5, 2026
f0d66c4
fix
gongfuture Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public PushProvider createPushProvider(String name, String type, ConfigurationSe
case "pushdeer" -> provider = PushDeerPushProvider.loadFromYaml(name, section, httpUtil);
case "gotify" -> provider = GotifyPushProvider.loadFromYaml(name, section, httpUtil);
case "ntfy" -> provider = NtfyPushProvider.loadFromYaml(name, section, httpUtil);
case "webhook" -> provider = WebhookPushProvider.loadFromYaml(name, section, httpUtil);
default -> provider = null;
Comment thread
gongfuture marked this conversation as resolved.
}
return provider;
}
Expand All @@ -63,6 +65,8 @@ public PushProvider createPushProvider(String name, String type, JsonObject json
case "pushdeer" -> provider = PushDeerPushProvider.loadFromJson(name, jsonObject, httpUtil);
case "gotify" -> provider = GotifyPushProvider.loadFromJson(name, jsonObject, httpUtil);
case "ntfy" -> provider = NtfyPushProvider.loadFromJson(name, jsonObject, httpUtil);
case "webhook" -> provider = WebhookPushProvider.loadFromJson(name, jsonObject, httpUtil);
default -> provider = null;
}
return provider;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
package com.ghostchu.peerbanhelper.util.push.impl;

import com.ghostchu.peerbanhelper.util.HTTPUtil;
import com.ghostchu.peerbanhelper.util.TimeUtil;
import com.ghostchu.peerbanhelper.util.json.JsonUtil;
import com.ghostchu.peerbanhelper.util.push.AbstractPushProvider;
import com.google.gson.JsonObject;
import com.google.gson.annotations.SerializedName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.bspfsystems.yamlconfiguration.configuration.ConfigurationSection;
import org.bspfsystems.yamlconfiguration.file.YamlConfiguration;

import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class WebhookPushProvider extends AbstractPushProvider {
private static final String DEFAULT_METHOD = "POST";
private static final String DEFAULT_CONTENT_TYPE = "application/json";
private static final String DEFAULT_BODY_TEMPLATE = """
{
"title":"{title}",
"content":"{content}",
"level":"{level}",
"date":"{date}",
"time":"{time}",
"datetime":"{datetime}",
"channelName":"{channelName}"
}
""";
//TODO: 改方法签名并重构各模块以支持 Level 参数
private static final Pattern LEVEL_PATTERN = Pattern.compile("\\[PeerBanHelper/([^]]+)]");
Comment thread
gongfuture marked this conversation as resolved.
Outdated

private final Config config;
private final String name;
private final HTTPUtil httpUtil;

public WebhookPushProvider(String name, Config config, HTTPUtil httpUtil) {
this.name = name;
this.config = config;
this.httpUtil = httpUtil;
}

@Override
public String getName() {
return name;
}

@Override
public String getConfigType() {
return "webhook";
}

@Override
public JsonObject saveJson() {
return JsonUtil.readObject(JsonUtil.standard().toJson(config));
}

@Override
public ConfigurationSection saveYaml() {
YamlConfiguration section = new YamlConfiguration();
section.set("type", "webhook");
section.set("url", config.getUrl());
section.set("method", config.getMethod());
section.set("content_type", config.getContentType());
section.set("body_template", config.getBodyTemplate());
Comment thread
gongfuture marked this conversation as resolved.
Outdated
section.set("headers", config.getHeaders());
return section;
}

public static WebhookPushProvider loadFromJson(String name, JsonObject json, HTTPUtil httpUtil) {
Config config = JsonUtil.getGson().fromJson(json, Config.class);
if (config == null) {
Comment thread
gongfuture marked this conversation as resolved.
Outdated
throw new IllegalArgumentException("Invalid webhook config JSON for provider '" + name + "': deserialized Config is null");
}
if (config.getMethod() == null || config.getMethod().isBlank()) {
config.setMethod(DEFAULT_METHOD);
}
if (config.getContentType() == null || config.getContentType().isBlank()) {
config.setContentType(DEFAULT_CONTENT_TYPE);
}
if (config.getBodyTemplate() == null || config.getBodyTemplate().isBlank()) {
config.setBodyTemplate(DEFAULT_BODY_TEMPLATE);
}
if (config.getHeaders() == null) {
config.setHeaders(new HashMap<>());
Comment thread
gongfuture marked this conversation as resolved.
Outdated
}
return new WebhookPushProvider(name, config, httpUtil);
}
Comment thread
gongfuture marked this conversation as resolved.

public static WebhookPushProvider loadFromYaml(String name, ConfigurationSection section, HTTPUtil httpUtil) {
var url = section.getString("url", "");
var method = section.getString("method", DEFAULT_METHOD);
var contentType = section.getString("content_type", DEFAULT_CONTENT_TYPE);
var bodyTemplate = section.getString(
"body_template",
Comment thread
gongfuture marked this conversation as resolved.
Outdated
DEFAULT_BODY_TEMPLATE
);
Map<String, String> headers = new HashMap<>();
var headersSection = section.getConfigurationSection("headers");
if (headersSection != null) {
for (String key : headersSection.getKeys(false)) {
headers.put(key, headersSection.getString(key, ""));
}
}
Config config = new Config(url, method, contentType, bodyTemplate, headers);
return new WebhookPushProvider(name, config, httpUtil);
}

@Override
public boolean push(String title, String content) {
if (config.getUrl() == null || config.getUrl().isBlank()) {
throw new IllegalArgumentException("Webhook URL cannot be empty");
}

String method = normalizeMethod(config.getMethod());
String contentType = normalizeContentType(config.getContentType());
String bodyTemplate = config.getBodyTemplate() == null ? "" : config.getBodyTemplate();
String renderedBody = renderTemplate(bodyTemplate, title, content, contentType);
RequestBody requestBody = createRequestBody(method, contentType, renderedBody);

String renderedUrl = renderTemplate(config.getUrl(), title, content, null);
Request.Builder requestBuilder = new Request.Builder().url(renderedUrl).method(method, requestBody);
Comment thread
gongfuture marked this conversation as resolved.
Outdated

applyContentType(requestBuilder, method, requestBody);
applyCustomHeaders(requestBuilder);
Request request = requestBuilder.build();
try (Response response = httpUtil.newBuilder().build().newCall(request).execute()) {
Comment thread
Ghost-chu marked this conversation as resolved.
if (!response.isSuccessful()) {
String responseBody = response.body() != null ? response.body().string() : "<empty>";
Comment thread
gongfuture marked this conversation as resolved.
throw new IllegalStateException("HTTP failed while sending push messages to Webhook: " + responseBody);
}
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("Failed to send push message to Webhook", e);
}
return true;
Comment thread
gongfuture marked this conversation as resolved.
}

private String normalizeMethod(String method) {
if (method == null || method.isBlank()) {
return DEFAULT_METHOD;
}
String normalized = method.toUpperCase(Locale.ROOT);
return switch (normalized) {
case "GET", "POST" -> normalized;
default -> throw new IllegalArgumentException("Unsupported webhook method: " + method);
};
}

private String normalizeContentType(String contentType) {
if (contentType == null || contentType.isBlank()) {
return DEFAULT_CONTENT_TYPE;
}
MediaType parsed = MediaType.parse(contentType.trim());
return parsed != null ? parsed.toString() : DEFAULT_CONTENT_TYPE;
}

private RequestBody createRequestBody(String method, String contentType, String bodyContent) {
if ("GET".equals(method)) {
return null;
}
MediaType mediaType = MediaType.parse(contentType);
if (mediaType == null) {
mediaType = MediaType.parse(DEFAULT_CONTENT_TYPE);
}
if (bodyContent.isEmpty()) {
if ("POST".equals(method)) {
return RequestBody.create("", mediaType);
}
return null;
}
return RequestBody.create(bodyContent, mediaType);
}
Comment thread
gongfuture marked this conversation as resolved.

private void applyContentType(Request.Builder requestBuilder, String method, RequestBody requestBody) {
if (!"GET".equals(method) && requestBody != null) {
MediaType mediaType = requestBody.contentType();
if (mediaType != null) {
requestBuilder.header("Content-Type", mediaType.toString());
} else {
requestBuilder.header("Content-Type", DEFAULT_CONTENT_TYPE);
}
}
}

private void applyCustomHeaders(Request.Builder requestBuilder) {
if (config.getHeaders() == null) {
return;
}
config.getHeaders().forEach((headerKey, headerValue) -> {
if (headerKey != null && !headerKey.isBlank() && headerValue != null) {
requestBuilder.header(headerKey, headerValue);
}
});
}

private String renderTemplate(String template, String title, String content, String contentType) {
OffsetDateTime now = OffsetDateTime.now();
String date = TimeUtil.formatDateOnly(System.currentTimeMillis());
String time = TimeUtil.formatTimeOnly(System.currentTimeMillis());
String datetime = TimeUtil.formatDateTime(System.currentTimeMillis());
Comment thread
gongfuture marked this conversation as resolved.
Outdated
Comment thread
gongfuture marked this conversation as resolved.
Outdated
boolean json = contentType != null && contentType.toLowerCase(Locale.ROOT).contains("json");
java.util.function.UnaryOperator<String> esc = v -> {
if (v == null) return "";
if (!json) return v;
String s = JsonUtil.standard().toJson(v); // 引号包裹的转义字符串
return s.substring(1, s.length() - 1); // 去掉外层引号
};
return template
.replace("{title}", esc.apply(title))
.replace("{content}", esc.apply(content))
.replace("{level}", esc.apply(extractLevel(title)))
.replace("{date}", esc.apply(date))
.replace("{time}", esc.apply(time))
.replace("{datetime}", esc.apply(datetime))
.replace("{channelName}", esc.apply(name));
}

private String extractLevel(String title) {
Matcher matcher = LEVEL_PATTERN.matcher(title);
if (matcher.find()) {
return matcher.group(1);
}
return "INFO";
}

@AllArgsConstructor
@NoArgsConstructor
@Data
public static class Config {
Comment thread
gongfuture marked this conversation as resolved.
@SerializedName("url")
private String url;
@SerializedName("method")
private String method;
@SerializedName("content_type")
private String contentType;
@SerializedName("body_template")
private String bodyTemplate;
@SerializedName("headers")
private Map<String, String> headers;
}
}
1 change: 1 addition & 0 deletions src/main/resources/assets/credit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ PeerBanHelper Credit
cxzlw
david082321
DDSRem
gongfuture
Kaffu-Chino
mahoshojoHCG
MirrorCY
Expand Down
22 changes: 21 additions & 1 deletion webui/src/api/model/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ export enum PushType {
Bark = 'bark',
PushDeer = 'pushdeer',
Gotify = 'gotify',
Ntfy = 'ntfy'
Ntfy = 'ntfy',
Webhook = 'webhook'
}

export enum WebhookMethod {
GET = 'GET',
POST = 'POST'
}

export enum WebhookContentType {
Json = 'application/json',
Comment thread
gongfuture marked this conversation as resolved.
Outdated
PlainText = 'text/plain'
}

export enum SMTPEncryption {
Expand Down Expand Up @@ -79,6 +90,14 @@ export interface NtfyConfig {
tags?: string
}

export interface WebhookConfig {
url: string
method: WebhookMethod
content_type: WebhookContentType
body_template: string
headers: Record<string, string>
}

export type PushConfig = {
name: string
} & (
Expand All @@ -90,4 +109,5 @@ export type PushConfig = {
| { type: PushType.PushDeer; config: PushDeerConfig }
| { type: PushType.Gotify; config: GotifyConfig }
| { type: PushType.Ntfy; config: NtfyConfig }
| { type: PushType.Webhook; config: WebhookConfig }
)
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
<a-radio :value="PushType.Ntfy">{{
t('page.settings.tab.config.push.form.type.' + PushType.Ntfy)
}}</a-radio>
<a-radio :value="PushType.Webhook">{{
t('page.settings.tab.config.push.form.type.' + PushType.Webhook)
}}</a-radio>
</a-grid>
</a-radio-group>
</a-form-item>
Expand Down Expand Up @@ -128,6 +131,9 @@ const formMap: Record<PushType, Component> = {
),
[PushType.Ntfy]: defineAsyncComponent(
() => import('@/views/settings/components/config/components/push/forms/ntfyForm.vue')
),
[PushType.Webhook]: defineAsyncComponent(
() => import('@/views/settings/components/config/components/push/forms/webhookForm.vue')
)
}

Expand Down
Loading
Loading