1725 功能大改

This commit is contained in:
Vaica committed 2026-06-24 17:25:54 +08:00
1 parent fa52190830
commit f73bf33bce
10 files changed
+1743 -10

No files matched your search

+345
View File
@@ -0,0 +1,345 @@
/* ============================================================
Shortcodes 样式 - 实况照片、语音、音乐卡片
移植自 Hugo-Theme-Amigo
============================================================ */
/* ---- Live Photo ---- */
.live-photo {
--border-radius: 4px;
--live-photo-aspect: 4 / 5;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
width: 100%;
max-width: min(360px, 100%);
margin: 12px auto;
aspect-ratio: var(--live-photo-aspect);
background: #f5f5f5;
container-type: inline-size;
}
.live-photo .live-photo-video {
position: absolute;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
inset: 0;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
}
.live-photo .live-photo-poster {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 1;
transition: opacity 0.25s ease;
z-index: 0;
}
.live-photo.is-playing .live-photo-video {
opacity: 1;
}
.live-photo.is-playing .live-photo-poster {
opacity: 0;
}
.live-photo .live-photo-controls-group {
position: absolute;
left: 10px;
top: 10px;
display: flex;
align-items: center;
gap: 6px;
z-index: 2;
}
.live-photo .live-photo-control-btn {
border: none;
padding: 4px 8px;
border-radius: var(--border-radius);
height: 24px;
display: inline-flex;
align-items: center;
gap: 6px;
background-color: transparent;
color: #606060;
cursor: pointer;
user-select: none;
border: 1px solid rgba(255, 255, 255, 0.7);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.live-photo .live-photo-control-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
background-color: transparent;
}
.live-photo .live-photo-toggle-btn {
background-color: rgba(44, 44, 46, 0.62);
border-color: rgba(255, 255, 255, 0.22);
color: rgb(205 205 205 / 92%);
}
.live-photo .live-photo-toggle-btn:hover {
background-color: rgba(44, 44, 46, 0.72);
}
.live-photo .live-photo-mute-btn {
background-color: rgba(44, 44, 46, 0.35);
border-color: rgba(255, 255, 255, 0.18);
color: rgb(205 205 205 / 92%);
}
.live-photo .live-photo-mute-btn:hover {
background-color: rgba(44, 44, 46, 0.42);
}
.live-photo-mute-btn .live-photo-mute-icon {
display: block;
width: 18px;
height: 18px;
font-size: 15px;
line-height: 19px;
color: rgb(205 205 205 / 92%);
}
.live-photo-mute-btn[data-muted="true"] .icon-unmuted {
display: none;
}
.live-photo-mute-btn[data-muted="false"] .icon-muted {
display: none;
}
/* ---- Voice Bubble ---- */
.amigo-voice-bubble {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(135deg, #95ec69 0%, #7dd652 100%);
border-radius: 16px;
cursor: pointer;
position: relative;
max-width: 200px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.amigo-voice-bubble:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.amigo-voice-bubble:active {
transform: translateY(0);
}
.amigo-voice-icon {
display: flex;
align-items: center;
gap: 2px;
height: 20px;
}
.amigo-voice-icon span {
display: block;
width: 3px;
background: #2d2d2d;
border-radius: 2px;
animation: voiceWave 1.2s ease-in-out infinite;
}
.amigo-voice-icon span:nth-child(1) {
height: 8px;
animation-delay: 0s;
}
.amigo-voice-icon span:nth-child(2) {
height: 14px;
animation-delay: 0.2s;
}
.amigo-voice-icon span:nth-child(3) {
height: 10px;
animation-delay: 0.4s;
}
.amigo-voice-bubble.playing .amigo-voice-icon span {
animation: voiceWaveActive 0.6s ease-in-out infinite;
}
.amigo-voice-duration {
font-size: 14px;
color: #2d2d2d;
font-weight: 500;
min-width: 24px;
}
.amigo-voice-unread {
position: absolute;
top: -4px;
right: -4px;
width: 8px;
height: 8px;
background: #ff4d4f;
border-radius: 50%;
border: 2px solid #fff;
}
@keyframes voiceWave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1); }
}
@keyframes voiceWaveActive {
0%, 100% { transform: scaleY(0.3); }
50% { transform: scaleY(1); }
}
/* ---- Music Card ---- */
.amigo-music-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
max-width: 400px;
margin: 16px 0;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.amigo-music-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
}
.amigo-music-card__art {
position: relative;
width: 80px;
height: 80px;
flex-shrink: 0;
}
.amigo-music-card__cover {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
.amigo-music-card__play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: none;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
}
.amigo-music-card__art:hover .amigo-music-card__play {
opacity: 1;
}
.amigo-music-card__play-icon {
width: 36px;
height: 36px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain;
}
.amigo-music-card__meta {
flex: 1;
min-width: 0;
}
.amigo-music-card__title {
display: block;
font-size: 16px;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amigo-music-card__artist {
display: block;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 12px;
}
.amigo-music-card__timeline {
display: flex;
align-items: center;
gap: 8px;
}
.amigo-music-card__time {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
min-width: 36px;
}
.amigo-music-card__seek-wrap {
flex: 1;
position: relative;
}
.amigo-music-card__seek {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.amigo-music-card__seek::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* Dark theme support */
[data-theme="dark"] .live-photo {
background: #1a1a1a;
}
[data-theme="dark"] .amigo-voice-bubble {
background: linear-gradient(135deg, #3d3d3d 0%, #2d2d2d 100%);
}
[data-theme="dark"] .amigo-voice-icon span,
[data-theme="dark"] .amigo-voice-duration {
color: #fff;
}
[data-theme="dark"] .amigo-music-card {
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Shortcodes JavaScript - 实况照片、语音、音乐卡片
* 移植自 Hugo-Theme-Amigo
*/
// ============================================================
// Live Photo
// ============================================================
function initLivePhotoShortcodes() {
document.querySelectorAll('.live-photo').forEach(function(livePhoto) {
if (livePhoto.__liveBound) return;
livePhoto.__liveBound = true;
var video = livePhoto.querySelector('video.live-photo-video') || livePhoto.querySelector('video');
var posterImg = livePhoto.querySelector('img.live-photo-poster') || livePhoto.querySelector('img');
var toggleBtn = livePhoto.querySelector('.live-photo-toggle-btn');
var muteBtn = livePhoto.querySelector('.live-photo-mute-btn');
if (!video || !toggleBtn || !muteBtn) return;
var HOVER_DELAY = 500;
var hoverTimer = null;
var isLoaded = false;
function syncAspectFromPoster() {
if (!posterImg) return;
var w = posterImg.naturalWidth || 0;
var h = posterImg.naturalHeight || 0;
if (!w || !h) return;
livePhoto.style.setProperty('--live-photo-aspect', w + ' / ' + h);
}
if (posterImg && posterImg.complete) {
syncAspectFromPoster();
} else if (posterImg) {
posterImg.addEventListener('load', syncAspectFromPoster, { once: true });
}
function ensureLoaded() {
if (isLoaded) return;
isLoaded = true;
var src = video.dataset.src || '';
if (src && !video.getAttribute('src')) {
video.setAttribute('src', src);
video.src = src;
}
try { video.load(); } catch (e) {}
}
function setMuted(isMuted) {
video.muted = !!isMuted;
if (isMuted) video.setAttribute('muted', '');
else video.removeAttribute('muted');
muteBtn.setAttribute('data-muted', isMuted ? 'true' : 'false');
}
setMuted(true);
function playVideo() {
ensureLoaded();
livePhoto.classList.add('is-playing');
toggleBtn.setAttribute('data-state', 'live');
try { video.play(); } catch (e) {}
}
function stopVideo() {
livePhoto.classList.remove('is-playing');
toggleBtn.setAttribute('data-state', 'static');
try { video.pause(); video.currentTime = 0; } catch (e) {}
}
// Toggle button click
toggleBtn.addEventListener('click', function(e) {
e.stopPropagation();
if (livePhoto.classList.contains('is-playing')) {
stopVideo();
} else {
playVideo();
}
});
// Mute button click
muteBtn.addEventListener('click', function(e) {
e.stopPropagation();
setMuted(!video.muted);
});
// Hover to play
livePhoto.addEventListener('mouseenter', function() {
hoverTimer = setTimeout(playVideo, HOVER_DELAY);
});
livePhoto.addEventListener('mouseleave', function() {
clearTimeout(hoverTimer);
if (toggleBtn.getAttribute('data-state') !== 'live') {
stopVideo();
}
});
// Mobile touch
livePhoto.addEventListener('touchstart', function() {
if (livePhoto.classList.contains('is-playing')) {
stopVideo();
} else {
playVideo();
}
});
// Video ended
video.addEventListener('ended', function() {
if (toggleBtn.getAttribute('data-state') !== 'live') {
stopVideo();
}
});
});
}
// ============================================================
// Voice Bubble
// ============================================================
function initVoiceBubbles() {
document.querySelectorAll('.amigo-voice-bubble').forEach(function(bubble) {
if (bubble.__voiceBound) return;
bubble.__voiceBound = true;
var audioSrc = bubble.dataset.src;
var audio = null;
var isPlaying = false;
var durationSpan = bubble.querySelector('.amigo-voice-duration');
// Load audio duration
if (audioSrc) {
audio = new Audio(audioSrc);
audio.addEventListener('loadedmetadata', function() {
var duration = Math.round(audio.duration);
if (durationSpan) {
durationSpan.textContent = duration + '"';
}
});
}
bubble.addEventListener('click', function() {
if (!audio) return;
if (isPlaying) {
audio.pause();
audio.currentTime = 0;
bubble.classList.remove('playing');
isPlaying = false;
} else {
// Stop other playing voices
document.querySelectorAll('.amigo-voice-bubble.playing').forEach(function(other) {
if (other !== bubble) {
other.classList.remove('playing');
}
});
audio.play();
bubble.classList.add('playing');
isPlaying = true;
}
});
audio.addEventListener('ended', function() {
bubble.classList.remove('playing');
isPlaying = false;
});
});
}
// ============================================================
// Music Card
// ============================================================
function initMusicCards() {
document.querySelectorAll('.amigo-music-card').forEach(function(card) {
if (card.__musicBound) return;
card.__musicBound = true;
var audioSrc = card.dataset.src;
var playBtn = card.querySelector('.amigo-music-card__play');
var seekInput = card.querySelector('.amigo-music-card__seek');
var currentTime = card.querySelector('.amigo-music-card__time--current');
var durationTime = card.querySelector('.amigo-music-card__time--duration');
var audio = null;
var isPlaying = false;
if (!audioSrc) return;
audio = new Audio(audioSrc);
// Load metadata
audio.addEventListener('loadedmetadata', function() {
var duration = formatTime(audio.duration);
if (durationTime) durationTime.textContent = duration;
});
// Update progress
audio.addEventListener('timeupdate', function() {
if (currentTime) currentTime.textContent = formatTime(audio.currentTime);
if (seekInput && audio.duration) {
seekInput.value = (audio.currentTime / audio.duration * 1000).toString();
}
});
// Play/Pause
function togglePlay() {
if (isPlaying) {
audio.pause();
card.classList.remove('playing');
isPlaying = false;
} else {
// Stop other playing music
document.querySelectorAll('.amigo-music-card.playing').forEach(function(other) {
if (other !== card) {
other.classList.remove('playing');
}
});
audio.play();
card.classList.add('playing');
isPlaying = true;
}
}
if (playBtn) {
playBtn.addEventListener('click', function(e) {
e.stopPropagation();
togglePlay();
});
}
card.addEventListener('click', togglePlay);
// Seek
if (seekInput) {
seekInput.addEventListener('input', function() {
if (audio.duration) {
audio.currentTime = (parseFloat(seekInput.value) / 1000) * audio.duration;
}
});
}
// Ended
audio.addEventListener('ended', function() {
card.classList.remove('playing');
isPlaying = false;
});
});
}
function formatTime(seconds) {
var m = Math.floor(seconds / 60);
var s = Math.floor(seconds % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
// ============================================================
// Initialize all shortcodes
// ============================================================
function initAllShortcodes() {
initLivePhotoShortcodes();
initVoiceBubbles();
initMusicCards();
}
// DOMContentLoaded
document.addEventListener('DOMContentLoaded', initAllShortcodes);
// Page load
window.addEventListener('load', initAllShortcodes);
// PJAX support - re-initialize after page transition
document.addEventListener('pjax:complete', initAllShortcodes);
document.addEventListener('pjax:success', initAllShortcodes);
// Also listen for custom events that some PJAX libraries fire
document.addEventListener('page:loaded', initAllShortcodes);
document.addEventListener('page:change', initAllShortcodes);
+2 -1
View File
@@ -170,8 +170,9 @@
{{/* ====== 5. 延迟加载的非关键JS ====== */}}
{{ $toast := resources.Get "js/modules/toast.js" }}
{{ $easylazyload := resources.Get "js/modules/lazyload.js" }}
{{ $shortcodesJs := resources.Get "js/shortcodes.js" }}
{{ $deferredScripts := slice $toast $easylazyload | resources.Concat "js/deferred.js" | resources.Minify | resources.Fingerprint }}
{{ $deferredScripts := slice $toast $easylazyload $shortcodesJs | resources.Concat "js/deferred.js" | resources.Minify | resources.Fingerprint }}
<script>
// 使用requestIdleCallback在浏览器空闲时加载
+2 -1
View File
@@ -131,8 +131,9 @@
{{ $nprogress := resources.Get "css/libs/nprogress.min.css" }}
{{ $message := resources.Get "css/libs/message.min.css" }}
{{ $main := resources.Get "css/main.css" }}
{{ $shortcodes := resources.Get "css/shortcodes.css" }}
{{ $style := slice $normalize $remixicon $uikit $nprogress $message $main | resources.Concat "css/style.css" | resources.Minify | resources.Fingerprint }}
{{ $style := slice $normalize $remixicon $uikit $nprogress $message $main $shortcodes | resources.Concat "css/style.css" | resources.Minify | resources.Fingerprint }}
<link rel="stylesheet" href="{{ $style.RelPermalink }}">
{{- if and .Site.Params.style.enableBackgroundImage .Site.Params.style.backgroundImageUrl -}}
@@ -0,0 +1,35 @@
{{- $imageRaw := .Get "image" | default (.Get "src") -}}
{{- $alt := .Get "alt" | default "Live Photo" -}}
{{- $videoRaw := .Get "video" | default (replaceRE "\\.(avif|webp|jpg|jpeg|png|gif)$" ".mp4" $imageRaw) -}}
{{- $image := $imageRaw -}}
{{- $video := $videoRaw -}}
{{- if and $imageRaw (not (findRE "^(https?://|/)" $imageRaw)) -}}
{{- with .Page.Resources.GetMatch $imageRaw -}}
{{- $image = .RelPermalink -}}
{{- end -}}
{{- end -}}
{{- if and $videoRaw (not (findRE "^(https?://|/)" $videoRaw)) -}}
{{- with .Page.Resources.GetMatch $videoRaw -}}
{{- $video = .RelPermalink -}}
{{- end -}}
{{- end -}}
{{- $style := .Get "style" -}}
<div class="live-photo"{{ with $style }}{{ printf " style=%q" (. | safeCSS) | safeHTMLAttr }}{{ end }}>
<img class="live-photo-poster" src="{{ $image }}" alt="{{ $alt }}" loading="lazy" data-keep="1" no-view>
<video class="live-photo-video" data-src="{{ $video }}" poster="{{ $image }}" muted playsinline loop preload="metadata" aria-label="{{ $alt }}"></video>
<div class="live-photo-controls-group">
<button type="button" class="live-photo-control-btn live-photo-toggle-btn" data-state="static" aria-label="Toggle Live Photo">
<i class="icon-live" aria-hidden="true"></i>
<span class="live-photo-live-label">实况</span>
</button>
<button type="button" class="live-photo-control-btn live-photo-mute-btn" data-muted="true" aria-label="Toggle Mute">
<i class="ri-volume-up-line live-photo-mute-icon icon-unmuted" aria-hidden="true"></i>
<i class="ri-volume-mute-line live-photo-mute-icon icon-muted" aria-hidden="true"></i>
<span class="live-photo-mute-label">静音</span>
</button>
</div>
<div class="warning" style="opacity: 0;"></div>
</div>
@@ -0,0 +1,33 @@
{{ $src := .Get "src" | default "" }}
{{ $cover := .Get "cover" | default "/images/default-cover.jpg" }}
{{ $name := .Get "name" | default "未知歌曲" }}
{{ $artist := .Get "artist" | default "未知艺术家" }}
{{ $accent := .Get "accent" | default "#d43c33" }}
{{ $isApi := strings.HasPrefix $src "https://open.motues.top/music" }}
<div class="amigo-music-card-mark amigo-music-card" data-amigo-music-card="1" data-src="{{ $src }}" data-name="{{ $name }}" data-artist="{{ $artist }}" data-is-api="{{ if $isApi }}true{{ else }}false{{ end }}" style="--music-accent:{{ $accent }}">
<div class="amigo-music-card__hero">
<div class="amigo-music-card__art">
<img class="amigo-music-card__cover amigo-music-card__cover--image" src="{{ $cover }}" alt="{{ $name }} cover" loading="lazy" data-keep="1">
<button type="button" class="amigo-music-card__play" aria-label="播放《{{ $name }}》" aria-pressed="false">
<span class="amigo-music-card__play-icon" aria-hidden="true"></span>
</button>
<span class="amigo-music-card__equalizer" aria-hidden="true"><span></span><span></span><span></span></span>
</div>
<div class="amigo-music-card__meta">
<strong class="amigo-music-card__title">{{ $name }}</strong>
<span class="amigo-music-card__subtitle">
<span class="amigo-music-card__artist">{{ $artist }}</span>
</span>
<div class="amigo-music-card__timeline">
<span class="amigo-music-card__time amigo-music-card__time--current">0:00</span>
<label class="amigo-music-card__seek-wrap">
<span class="sr-only">调整《{{ $name }}》的播放进度</span>
<input class="amigo-music-card__seek" type="range" min="0" max="1000" step="1" value="0" aria-label="调整《{{ $name }}》的播放进度">
<span class="amigo-music-card__seek-thumb" aria-hidden="true"></span>
</label>
<span class="amigo-music-card__time amigo-music-card__time--duration">--:--</span>
</div>
</div>
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
{{- $src := .Get "src" | default "" -}}
{{- if and $src (not (findRE "^(https?://|/)" $src)) -}}
{{- with .Page.Resources.GetMatch $src -}}
{{- $src = .RelPermalink -}}
{{- end -}}
{{- end -}}
{{- $isToday := eq (.Page.Date.Format "2006-01-02") (now.Format "2006-01-02") -}}
<div class="amigo-voice-bubble" data-src="{{ $src }}">
<div class="amigo-voice-icon">
<span></span><span></span><span></span>
</div>
<span class="amigo-voice-duration">″</span>
{{ if $isToday }}<i class="amigo-voice-unread"></i>{{ end }}
</div>
+238
View File
@@ -0,0 +1,238 @@
# Hugo 短代码功能 & Telegram Bot 适配
## ✅ 已实现功能
### 1️⃣ Hugo 短代码
#### 实况照片 (Live Photo)
```markdown
{{< livephoto image="photo.jpg" video="photo.mp4" >}}
```
- 悬停播放视频
- 点击切换播放/暂停
- 静音控制
- 自动检测图片比例
#### 语音气泡
```markdown
{{< voice src="voice.ogg" >}}
```
- 微信风格设计
- 点击播放语音
- 自动显示时长
- 未读标记(当天发布)
#### 音乐卡片
```markdown
{{< music-card src="song.mp3" name="歌曲名" artist="歌手" cover="cover.jpg" >}}
```
- 精美卡片设计
- 播放/暂停控制
- 进度条拖拽
- 时间显示
---
## 📱 Telegram Bot 使用指南
### 发送语音消息
**操作**:
1. 在写作模式下(`/new` 或 `/edit`)
2. 发送语音消息
**Bot 响应**:
```
🎙️ 正在处理语音...
✅ 语音已保存:1719234567-voice.ogg
📝 转文字:今天天气真好...
```
**生成内容**:
```markdown
{{< voice src="1719234567-voice.ogg" >}}
> 今天天气真好...
```
---
### 发送音频文件(MP3)
**操作**:
1. 在写作模式下
2. 发送 MP3 文件
**Bot 响应**:
```
🎵 正在处理音频文件...
✅ 音频已保存:1719234567-music.mp3
请输入歌曲信息(格式:歌名 - 歌手):
```
**用户回复**:
```
晴天 - 周杰伦
```
**Bot 响应**:
```
🎵 歌曲:晴天
🎤 歌手:周杰伦
是否添加封面图?
[📷 添加封面图] [⏭️ 跳过]
```
**选项 A - 添加封面图**:
1. 点击 "添加封面图"
2. 发送图片
3. Bot:✅ 封面图已保存:cover.jpg
4. 生成带封面的音乐卡片
**选项 B - 跳过**:
1. 点击 "跳过"
2. 直接生成不带封面的音乐卡片
**生成内容**:
```markdown
{{< music-card src="1719234567-music.mp3" name="晴天" artist="周杰伦" cover="cover.jpg" >}}
```
---
### 发送图片
**操作**:
1. 在写作模式下
2. 发送图片
**Bot 响应**:
```
📷 图片已保存:1719234567-photo.jpg
💡 提示:如果您发送的是 iOS 实况照片(Live Photo),
动态效果无法自动提取。如需保留动态效果,请:
1. 将实况照片转为视频/GIF 后重新发送
2. 或者单独发送视频部分
继续发送其他内容...
```
**生成内容**:
```markdown
![image](1719234567-photo.jpg)
```
---
## 🎯 功能特性
### PJAX 支持
- 所有短代码支持 PJAX 无刷新页面切换
- 自动重新初始化交互逻辑
- 保持播放状态
### 暗黑模式
- 自动适配暗黑主题
- 深色背景优化
- 高对比度设计
### 响应式设计
- 移动端友好
- 触摸操作支持
- 自适应布局
---
## 🛠️ 技术实现
### 文件结构
**Hugo 主题 (ying)**:
```
themes/ying/
├── layouts/shortcodes/
│ ├── livephoto.html
│ ├── voice.html
│ └── music-card.html
├── assets/css/
│ └── shortcodes.css
└── assets/js/
└── shortcodes.js
```
**Telegram Bot (write-server)**:
```
write-server/src/lib/bot/
├── poll.ts
│ ├── handleVoiceEnhanced() # 语音增强
│ ├── handleAudioEnhanced() # 音频处理
│ ├── handlePhotoEnhanced() # 图片增强
│ ├── handleAudioInfoEnhanced() # 音频信息
│ └── handleAudioCoverCallback() # 封面图回调
└── handlers.ts
```
### API 集成
- Artalk 评论系统
- Telegram Bot API
- Hugo 静态生成
---
## 📝 部署检查清单
- [ ] 提交 ying 主题修改
- [ ] 提交 write-server 修改
- [ ] 推送到远程仓库
- [ ] 重新构建 Hugo 站点
- [ ] 部署 write-server
- [ ] 测试语音消息
- [ ] 测试音频文件
- [ ] 测试图片上传
- [ ] 验证短代码渲染
- [ ] 测试 PJAX 页面切换
- [ ] 测试暗黑模式
---
## 🐛 故障排除
### 语音不播放
- 检查音频文件路径
- 确认文件格式支持(OGG, MP3)
- 查看浏览器控制台错误
### 音乐卡片无响应
- 检查 JS 是否正确加载
- 确认 PJAX 初始化
- 刷新页面重试
### 封面图不显示
- 检查图片路径
- 确认文件已保存
- 查看 Hugo 构建日志
---
## 📚 相关链接
- [Hugo 短代码文档](https://gohugo.io/content-management/shortcodes/)
- [Telegram Bot API](https://core.telegram.org/bots/api)
- [Amigo 主题](https://github.com/zqlit/Hugo-Theme-Amigo)
---
## 📊 版本历史
**v1.0.0** (2026-06-24)
- ✅ 移植 Amigo 主题短代码
- ✅ Telegram Bot 语音增强
- ✅ Telegram Bot 音频文件支持
- ✅ 封面图上传功能
- ✅ 实况照片引导提示
- ✅ PJAX 支持
- ✅ 暗黑模式适配
+413 -8
View File
@@ -79,18 +79,21 @@ export async function startPolling(): Promise<void> {
const chatId = msg.chat.id;
if (!allowed(chatId)) continue;
// 处理语音消息(转文字写入文章)
if ((msg.voice || msg.audio) && hasSession(chatId)) {
const voiceFile = msg.voice || msg.audio;
if (voiceFile) {
await handleVoice(chatId, voiceFile.file_id);
}
// 处理语音消息(增强版:保存文件 + 转文字 + 生成短代码)
if (msg.voice && hasSession(chatId)) {
await handleVoiceEnhanced(chatId, msg.voice.file_id, msg.voice.duration);
continue;
}
// 处理图片
// 处理音频文件(增强版:支持封面图)
if (msg.audio && hasSession(chatId)) {
await handleAudioEnhanced(chatId, msg.audio.file_id, msg.audio.file_name);
continue;
}
// 处理图片(增强版:支持封面图上传 + 实况照片引导)
if (msg.photo && hasSession(chatId)) {
await handlePhoto(chatId, msg.photo, msg.caption);
await handlePhotoEnhanced(chatId, msg.photo, msg.caption);
continue;
}
@@ -110,6 +113,13 @@ export async function startPolling(): Promise<void> {
// ForceReply routing
if (msg.reply_to_message?.text) {
const original = msg.reply_to_message.text;
// 处理音频信息回复(音乐卡片)
if (original.includes("请输入歌曲信息")) {
const handled = await handleAudioInfo(chatId, fullText);
if (handled) continue;
}
if (original.includes("请输入文章标题")) { await handleCommand(chatId, "new", fullText); continue; }
if (original.includes("请输入新标题")) { await handleCommand(chatId, "title", fullText); continue; }
if (original.includes("请输入分类")) { await handleCommand(chatId, "categories", fullText); continue; }
@@ -351,6 +361,13 @@ async function handleCommand(chatId: number, cmd: string, arg?: string) {
}
async function handleInlineCallback(chatId: number, queryId: string, data: string) {
// 处理音频封面图回调
if (data === "audio_add_cover" || data === "audio_skip_cover") {
await handleAudioCoverCallback(chatId, data);
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
return;
}
let prefix = "";
let arg = "";
// First try prefix match (e.g. "edit_slug", "list_2")
@@ -373,3 +390,391 @@ async function handleInlineCallback(chatId: number, queryId: string, data: strin
await handleCommand(chatId, cmd, arg);
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
}
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
await sendMessage(chatId, "🎙️ 正在处理语音...");
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: fileId });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 语音文件获取失败");
return;
}
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const audioBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = filePath.split(".").pop() || "ogg";
const filename = Date.now() + "-voice." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(audioBuffer));
const voiceShortcode = `\n\n{{< voice src="${filename}" >}}\n\n`;
appendContent(chatId, voiceShortcode);
try {
const text = await transcribeAudio(audioBuffer, filePath);
if (text.trim()) {
appendContent(chatId, `\n> ${text.trim()}\n\n`);
const preview = text.trim().length > 80 ? text.trim().slice(0, 80) + "..." : text.trim();
await sendMessage(chatId, `✅ 语音已保存:${filename}\n\n📝 转文字:${preview}`);
} else {
await sendMessage(chatId, `✅ 语音已保存:${filename}\n\n⚠️ 语音转文字失败,已插入语音播放器`);
}
} catch (transcribeError) {
console.error("[bot:voice-transcribe]", transcribeError);
await sendMessage(chatId, `✅ 语音已保存:${filename}\n\n⚠️ 语音转文字失败,已插入语音播放器`);
}
} catch (e) {
console.error("[bot:voice]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 语音处理失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
async function handleAudio(chatId: number, fileId: string, fileName?: string, mimeType?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
await sendMessage(chatId, "🎵 正在处理音频文件...");
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: fileId });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 音频文件获取失败");
return;
}
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const audioBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = fileName?.split(".").pop() || filePath.split(".").pop() || "mp3";
const filename = Date.now() + "-music." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(audioBuffer));
const session = getSession(chatId);
session._pendingAudio = { filename, fileId };
await tg("sendMessage", {
chat_id: chatId,
text: `✅ 音频已保存:${filename}\n\n请输入歌曲信息(格式:歌名 - 歌手):`,
reply_markup: {
force_reply: true,
input_field_placeholder: "例如:晴天 - 周杰伦"
}
});
} catch (e) {
console.error("[bot:audio]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 音频处理失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
async function handleAudioInfo(chatId: number, text: string): Promise<boolean> {
try {
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
return false;
}
const { filename } = s._pendingAudio;
delete s._pendingAudio;
let name = "未知歌曲";
let artist = "未知艺术家";
const parts = text.split(/[-–—]/);
if (parts.length >= 2) {
name = parts[0].trim();
artist = parts.slice(1).join("-").trim();
} else if (text.trim()) {
name = text.trim();
}
co// ============================================================
// 增强版 handlePhoto:支持封面图上传 + 实况照片引导
// ============================================================
async function handlePhotoEnhanced(chatId: number, photos: { file_id: string }[], caption?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
// 检查是否在等待封面图
if (s._pendingAudio) {
await handleAudioCover(chatId, photos);
return;
}
// 正常图片处理
const largestPhoto = photos[photos.length - 1];
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id });
if (!fileRes.ok || !fileRes.result?.file_path) return;
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const imgBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
// 保存图片
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = filePath.split(".").pop() || "jpg";
const filename = Date.now() + "-" + largestPhoto.file_id.slice(0, 8) + "." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(imgBuffer));
const imgCaption = caption || "image";
appendContent(chatId, `\n![${imgCaption}](${filename})\n`);
// 实况照片引导提示
await sendMessage(chatId, `📷 图片已保存:${filename}
💡 <b>提示:</b>如果您发送的是 iOS 实况照片(Live Photo),
动态效果无法自动提取。如需保留动态效果,请:
1. 将实况照片转为视频/GIF 后重新发送
2. 或者单独发送视频部分
继续发送其他内容...</b>`, { parse_mode: "HTML" });
} catch (e) {
console.error("[bot:photo]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 图片保存失败");
}
}
// ============================================================
// 处理音乐封面图
// ============================================================
async function handleAudioCover(chatId: number, photos: { file_id: string }[]) {
try {
const fs = await import("fs");
const path = await import("path");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
await sendMessage(chatId, "❌ 没有待处理的音频文件");
return;
}
const { filename } = s._pendingAudio;
// 获取最大的图片
const largestPhoto = photos[photos.length - 1];
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 封面图获取失败");
return;
}
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const imgBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
// 保存封面图
const articleDir = s.dirPath;
const ext = filePath.split(".").pop() || "jpg";
const coverFilename = Date.now() + "-cover." + ext;
fs.writeFileSync(path.join(articleDir, coverFilename), Buffer.from(imgBuffer));
// 更新音频信息
const audioInfo = s._pendingAudio;
delete s._pendingAudio;
// 生成带封面的音乐卡片
let name = "未知歌曲";
let artist = "未知艺术家";
if (audioInfo.name && audioInfo.artist) {
name = audioInfo.name;
artist = audioInfo.artist;
}
const musicCardShortcode = `\n\n{{< music-card src="${filename}" name="${name}" artist="${artist}" cover="${coverFilename}" >}}\n\n`;
appendContent(chatId, musicCardShortcode);
await sendMessage(chatId, `✅ 封面图已保存:${coverFilename}
🎵 <b>音乐卡片已插入:</b>
📌 歌曲:${name}
🎤 歌手:${artist}
🖼️ 封面:${coverFilename}`, { parse_mode: "HTML" });
} catch (e) {
console.error("[bot:audio-cover]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 封面图保存失败");
}
}
// ============================================================
// 增强版 handleAudio:支持封面图询问
// ============================================================
async function handleAudioEnhanced(chatId: number, fileId: string, fileName?: string, mimeType?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
await sendMessage(chatId, "🎵 正在处理音频文件...");
// 获取文件
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: fileId });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 音频文件获取失败");
return;
}
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const audioBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
// 保存音频文件
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = fileName?.split(".").pop() || filePath.split(".").pop() || "mp3";
const filename = Date.now() + "-music." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(audioBuffer));
// 询问歌曲信息
const session = getSession(chatId);
session._pendingAudio = { filename, fileId, name: null, artist: null };
await tg("sendMessage", {
chat_id: chatId,
text: `✅ 音频已保存:${filename}\n\n请输入歌曲信息(格式:歌名 - 歌手):`,
reply_markup: {
force_reply: true,
input_field_placeholder: "例如:晴天 - 周杰伦"
}
});
} catch (e) {
console.error("[bot:audio]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 音频处理失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
// ============================================================
// 增强版 handleAudioInfo:支持封面图询问
// ============================================================
async function handleAudioInfoEnhanced(chatId: number, text: string): Promise<boolean> {
try {
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
return false;
}
// 解析歌名和歌手
let name = "未知歌曲";
let artist = "未知艺术家";
const parts = text.split(/[-–—]/);
if (parts.length >= 2) {
name = parts[0].trim();
artist = parts.slice(1).join("-").trim();
} else if (text.trim()) {
name = text.trim();
}
// 保存歌曲信息
s._pendingAudio.name = name;
s._pendingAudio.artist = artist;
// 询问是否添加封面图
await tg("sendMessage", {
chat_id: chatId,
text: `🎵 歌曲:${name}\n🎤 歌手:${artist}\n\n是否添加封面图?`,
reply_markup: {
inline_keyboard: [
[
{ text: "📷 添加封面图", callback_data: "audio_add_cover" },
{ text: "⏭️ 跳过", callback_data: "audio_skip_cover" }
]
]
}
});
return true;
} catch (e) {
console.error("[bot:audio-info]", e instanceof Error ? e.message : String(e));
return false;
}
}
// 处理封面图选择回调
async function handleAudioCoverCallback(chatId: number, data: string) {
try {
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
await sendMessage(chatId, "❌ 没有待处理的音频文件");
return;
}
if (data === "audio_skip_cover") {
// 跳过封面图,直接生成音乐卡片
const { filename, name, artist } = s._pendingAudio;
delete s._pendingAudio;
const musicCardShortcode = `\n\n{{< music-card src="${filename}" name="${name}" artist="${artist}" >}}\n\n`;
appendContent(chatId, musicCardShortcode);
await sendMessage(chatId, `🎵 <b>音乐卡片已插入:</b>
📌 歌曲:${name}
🎤 歌手:${artist}`, { parse_mode: "HTML" });
} else if (data === "audio_add_cover") {
// 等待用户发送封面图
await tg("sendMessage", {
chat_id: chatId,
text: "🖼️ 请发送封面图片:",
reply_markup: {
force_reply: true,
input_field_placeholder: "发送一张图片作为封面"
}
});
}
} catch (e) {
console.error("[bot:audio-cover-callback]", e instanceof Error ? e.message : String(e));
}
}
+376
View File
@@ -0,0 +1,376 @@
import { TG_TOKEN } from "./config";
import { tg, allowed, sendMessage } from "./tg";
import { hasSession, getSession, appendContent } from "./sessions";
import handlers from "./handlers";
const ARG_COMMANDS = new Set([
"new", "title", "categories", "tags", "author",
"edit", "view", "delete",
"link_add", "link_edit", "link_del", "link_toggle",
"feed_add", "feed_edit", "feed_del",
"list", "links", "feeds", "read",
]);
const CALLBACK_PREFIXES: Record<string, string> = {
edit: "edit",
confirm_del: "delete",
cancel_del: "noop",
list: "list",
links: "links",
feeds: "feeds",
skip_cat: "skip_categories",
skip_tag: "skip_tags",
};
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export async function startPolling(): Promise<void> {
if (!TG_TOKEN) {
console.log("[bot] TG_BOT_TOKEN not set, skipping bot startup");
return;
}
console.log("[bot] starting Telegram polling...");
let lastOffset = 0;
let backoff = 3000;
async function poll() {
try {
const data = await tg<Record<string, unknown>[]>("getUpdates", {
offset: lastOffset + 1,
timeout: 30,
allowed_updates: ["message", "callback_query"],
});
if (data.ok && data.result) {
for (const update of data.result) {
lastOffset = update.update_id as number;
if (update.callback_query) {
const cb = update.callback_query as {
message?: { chat?: { id: number } };
from?: { id: number };
id: string;
data?: string;
};
const cbChatId = cb.message?.chat?.id ?? cb.from?.id ?? 0;
if (cb.data && allowed(cbChatId)) {
await handleInlineCallback(cbChatId, cb.id, cb.data);
}
continue;
}
const msg = update.message as {
chat?: { id: number };
photo?: { file_id: string }[];
document?: { file_id: string; file_name?: string; mime_type?: string };
animation?: { file_id: string; file_name?: string; mime_type?: string };
video?: { file_id: string; file_name?: string };
voice?: { file_id: string; duration?: number };
audio?: { file_id: string; file_name?: string; duration?: number };
text?: string;
caption?: string;
reply_to_message?: { text?: string };
} | undefined;
if (!msg?.chat?.id) continue;
const chatId = msg.chat.id;
if (!allowed(chatId)) continue;
// 处理语音消息(转文字写入文章)
if ((msg.voice || msg.audio) && hasSession(chatId)) {
const voiceFile = msg.voice || msg.audio;
if (voiceFile) {
await handleVoice(chatId, voiceFile.file_id);
}
continue;
}
// 处理图片
if (msg.photo && hasSession(chatId)) {
await handlePhoto(chatId, msg.photo, msg.caption);
continue;
}
// 处理 GIF / 文档 / 视频
if ((msg.animation || msg.document || msg.video) && hasSession(chatId)) {
const fileType = msg.animation ? "animation" : msg.video ? "video" : "document";
const file = msg.animation || msg.document || msg.video;
if (file) {
await handleDocument(chatId, file.file_id, file.file_name, msg.caption, fileType);
}
continue;
}
if (!msg.text) continue;
const fullText = msg.text.trim();
// ForceReply routing
if (msg.reply_to_message?.text) {
const original = msg.reply_to_message.text;
if (original.includes("请输入文章标题")) { await handleCommand(chatId, "new", fullText); continue; }
if (original.includes("请输入新标题")) { await handleCommand(chatId, "title", fullText); continue; }
if (original.includes("请输入分类")) { await handleCommand(chatId, "categories", fullText); continue; }
if (original.includes("请输入标签")) { await handleCommand(chatId, "tags", fullText); continue; }
if (original.includes("请输入友链信息")) { await handleCommand(chatId, "link_add", fullText); continue; }
if (original.includes("请输入新名称")) { await handlers.handleLinkEditReply(chatId, fullText); continue; }
if (original.includes("请输入订阅源 URL")) { await handleCommand(chatId, "feed_add", fullText); continue; }
if (original.includes("请输入新的订阅信息")) { await handlers.handleFeedEditReply(chatId, fullText); continue; }
}
const parts = fullText.split("@")[0].split(/\s+/);
const cmdRaw = parts[0].toLowerCase();
const slashCmd = cmdRaw.startsWith("/") ? cmdRaw.slice(1) : "";
const slashArg = parts.slice(1).join(" ");
// In writing session, non-slash text appends to content
if (hasSession(chatId) && !cmdRaw.startsWith("/")) {
try { appendContent(chatId, fullText); }
catch (e) { console.error("[bot:append]", e instanceof Error ? e.message : String(e)); }
continue;
}
if (cmdRaw.startsWith("/")) {
await handleCommand(chatId, slashCmd, slashArg);
}
}
}
backoff = 3000;
} catch (e) {
console.error("[bot:poll]", e instanceof Error ? e.message : String(e));
await sleep(backoff);
backoff = Math.min(backoff * 2, 60000);
}
await sleep(100);
setImmediate(poll);
}
// Register commands with Telegram
try {
await tg("setMyCommands", {
commands: [
{ command: "new", description: "写一篇文章" },
{ command: "edit", description: "编辑文章" },
{ command: "delete", description: "删除文章" },
{ command: "list", description: "文章列表" },
{ command: "publish", description: "保存发布" },
{ command: "draft", description: "保存草稿" },
{ command: "deploy", description: "推送到线上" },
{ command: "sync", description: "同步远程" },
{ command: "undo", description: "撤销最后一段" },
{ command: "preview", description: "预览" },
{ command: "links", description: "友链管理" },
{ command: "link_add", description: "添加友链" },
{ command: "link_edit", description: "编辑友链" },
{ command: "link_del", description: "删除友链" },
{ command: "link_toggle", description: "显示/隐藏友链" },
{ command: "feeds", description: "订阅管理" },
{ command: "feed_add", description: "添加订阅源" },
{ command: "feed_edit", description: "编辑订阅源" },
{ command: "feed_del", description: "删除订阅源" },
{ command: "feed_health", description: "订阅源健康检查" },
{ command: "read", description: "友链最新文章" },
{ command: "stats", description: "网站数据" },
{ command: "help", description: "帮助" },
],
});
console.log("[bot] commands registered");
} catch (e) {
console.error("[bot] failed to register commands:", e instanceof Error ? e.message : String(e));
}
poll();
}
async function handlePhoto(chatId: number, photos: { file_id: string }[], caption?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
const largestPhoto = photos[photos.length - 1];
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id });
if (!fileRes.ok || !fileRes.result?.file_path) return;
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const imgBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
// Save to article's own folder (Hugo page bundle)
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = filePath.split(".").pop() || "jpg";
const filename = Date.now() + "-" + largestPhoto.file_id.slice(0, 8) + "." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(imgBuffer));
const imgCaption = caption || "image";
appendContent(chatId, `\n![${imgCaption}](${filename})\n`);
await sendMessage(chatId, `📷 图片已保存:${filename}`);
} catch (e) {
console.error("[bot:photo]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 图片保存失败");
}
}
async function handleDocument(chatId: number, fileId: string, fileName?: string, caption?: string, fileType?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { execSync } = await import("child_process");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: fileId });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 文件获取失败(可能文件太大,Telegram 限制 20MB)");
return;
}
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const fileBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = fileName?.split(".").pop() || filePath.split(".").pop() || "bin";
const baseFilename = Date.now() + "-" + fileId.slice(0, 8);
const filename = baseFilename + "." + ext;
const fullPath = path.join(articleDir, filename);
fs.writeFileSync(fullPath, Buffer.from(fileBuffer));
const fileCaption = caption || fileName || "file";
if (fileType === "animation") {
// Telegram GIF → ffmpeg 转成真正的 GIF
const gifFilename = baseFilename + ".gif";
const gifPath = path.join(articleDir, gifFilename);
try {
await sendMessage(chatId, "🎞️ 正在转换 GIF...");
execSync(`ffmpeg -y -i "${fullPath}" -vf "fps=15,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" -loop 0 "${gifPath}"`, { timeout: 60000 });
fs.unlinkSync(fullPath);
appendContent(chatId, `\n![${fileCaption}](${gifFilename})\n`);
await sendMessage(chatId, `🎞️ GIF 已转换:${gifFilename}`);
} catch (e) {
console.error("[bot:gif-convert]", e instanceof Error ? e.message : String(e));
appendContent(chatId, `\n{{< video src="${filename}" loop="true" muted="true" >}}\n`);
await sendMessage(chatId, `🎞️ GIF 转换失败,已保存为视频:${filename}`);
}
return;
} else if (fileType === "video") {
appendContent(chatId, `\n{{< video src="${filename}" caption="${fileCaption}" >}}\n`);
await sendMessage(chatId, `🎬 视频已保存:${filename}`);
return;
} else {
const extLower = ext.toLowerCase();
const isImage = ["gif", "webp", "png", "jpg", "jpeg"].includes(extLower);
const isVideo = ["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(extLower);
if (isImage) {
appendContent(chatId, `\n![${fileCaption}](${filename})\n`);
} else if (isVideo) {
appendContent(chatId, `\n{{< video src="${filename}" caption="${fileCaption}" >}}\n`);
} else {
appendContent(chatId, `\n[${fileCaption}](${filename})\n`);
}
await sendMessage(chatId, `📎 文件已保存:${filename}`);
}
} catch (e) {
console.error("[bot:document]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 文件保存失败");
}
}
async function handleVoice(chatId: number, fileId: string) {
try {
const { transcribeAudio } = await import("@/lib/voice");
await sendMessage(chatId, "🎤 正在识别语音...");
// 获取文件路径
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: fileId });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 语音文件获取失败");
return;
}
// 下载语音文件
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + fileRes.result.file_path;
const audioBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
// 转文字
const text = await transcribeAudio(audioBuffer, fileRes.result.file_path);
if (!text.trim()) {
await sendMessage(chatId, "❌ 语音识别结果为空");
return;
}
// 追加到文章内容
appendContent(chatId, text.trim());
const preview = text.trim().length > 80 ? text.trim().slice(0, 80) + "..." : text.trim();
await sendMessage(chatId, `✅ 语音已转文字:\n\n${preview}`);
} catch (e) {
console.error("[bot:voice]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 语音转文字失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
async function handleCommand(chatId: number, cmd: string, arg?: string) {
try {
if (cmd === "noop") return;
if (ARG_COMMANDS.has(cmd)) {
const handler = handlers[cmd];
if (handler) await handler(chatId, arg);
else await sendMessage(chatId, "❌ 未知命令:/" + cmd);
} else {
const handler = handlers[cmd];
if (handler) await handler(chatId);
else if (cmd) await sendMessage(chatId, "❌ 未知命令:/" + cmd);
}
} catch (e) {
console.error("[bot:" + cmd + "]", e instanceof Error ? e.message : String(e));
try {
await sendMessage(chatId, "❌ " + (e instanceof Error ? e.message : "error").slice(0, 200));
} catch (e) { /* ignore */ }
}
}
async function handleInlineCallback(chatId: number, queryId: string, data: string) {
let prefix = "";
let arg = "";
// First try prefix match (e.g. "edit_slug", "list_2")
for (const p of Object.keys(CALLBACK_PREFIXES)) {
if (data.startsWith(p + "_")) {
prefix = p;
arg = data.slice(p.length + 1);
break;
}
}
// Then try exact match (e.g. "confirm_shutdown", "skip_cat")
if (!prefix && CALLBACK_PREFIXES[data]) {
prefix = data;
}
if (!prefix) {
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
return;
}
const cmd = CALLBACK_PREFIXES[prefix];
await handleCommand(chatId, cmd, arg);
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
}