Compare commits

..

6 Commits

7 changed files with 296 additions and 75 deletions

View File

@ -5,9 +5,16 @@ on:
types: [created] types: [created]
pull_request_review_comment: pull_request_review_comment:
types: [created] types: [created]
issues:
types: [opened]
permissions:
contents: write
issues: write
pull-requests: write
jobs: jobs:
opencode: bot:
if: | if: |
contains(github.event.comment.body, ' /oc') || contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') || startsWith(github.event.comment.body, '/oc') ||
@ -15,19 +22,33 @@ jobs:
startsWith(github.event.comment.body, '/opencode') startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
id-token: write
contents: read contents: read
pull-requests: read
issues: read
steps: steps:
- name: Checkout repository - name: Generate bot token
id: token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Configure git identity
run: |
git config --global user.name "takanashi-hoshino-agent[bot]"
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
- name: Run opencode - name: Run opencode
uses: anomalyco/opencode/github@latest uses: anomalyco/opencode/github@latest
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
with: with:
model: opencode/deepseek-v4-flash-free model: opencode/deepseek-v4-flash-free
use_github_token: true
prompt: ${{ secrets.OPENCODE_PROMPT }}

46
.github/workflows/pr-reviewer.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: opencode-review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
jobs:
review:
# 跳过 fork 来的 PRGitHub 不会把 secrets 传给 fork PR 触发的工作流,
# 跑了也必然失败,所以直接跳过避免噪音(平台限制,非配置问题)。
if: github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
steps:
- name: Generate bot token
id: token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Configure git identity
run: |
git config --global user.name "takanashi-hoshino-agent[bot]"
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
- name: Run opencode review
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
# 用自定义 GitHub App 生成的 bot token跟原 opencode.yml 一致,
# 评论 PR / 提交都走 takanashi-hoshino-agent[bot] 的身份。
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
with:
model: opencode/deepseek-v4-flash-free
use_github_token: true
prompt: ${{ secrets.OPENCODE_PROMPT }}

16
interface.d.ts vendored
View File

@ -2802,6 +2802,22 @@ export function voice_upload(
name: string name: string
data: string | Buffer data: string | Buffer
} }
imgFile?: {
name: string
data: string | Buffer
}
voiceListId: string | number
coverImgId?: string | number
categoryId: string | number
secondCategoryId: string | number
description: string
songName?: string
privacy?: string | number
publishTime?: string | number
autoPublish?: string | number
autoPublishText?: string
orderNo?: string | number
composedSongs?: string
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>

View File

@ -2,6 +2,7 @@ const { default: axios } = require('axios')
const fs = require('fs') const fs = require('fs')
var xml2js = require('xml2js') var xml2js = require('xml2js')
const uploadPlugin = require('../plugins/upload')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { getFileExtension, readFileChunk } = require('../util/fileHelper') const { getFileExtension, readFileChunk } = require('../util/fileHelper')
@ -19,15 +20,7 @@ function createDupkey() {
return s.join('') return s.join('')
} }
module.exports = async (query, request) => { module.exports = async (query, request, dependencies = {}) => {
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
if (!query.songFile) { if (!query.songFile) {
return Promise.reject({ return Promise.reject({
status: 500, status: 500,
@ -38,6 +31,19 @@ module.exports = async (query, request) => {
}) })
} }
const axiosRequest = dependencies.axios || axios
const uploadImage = dependencies.uploadPlugin || uploadPlugin
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const coverImgId = query.imgFile
? (await uploadImage(query, request)).imgId
: query.coverImgId
const tokenRes = await request( const tokenRes = await request(
`/api/nos/token/alloc`, `/api/nos/token/alloc`,
{ {
@ -53,7 +59,7 @@ module.exports = async (query, request) => {
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F') const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
const docId = tokenRes.body.result.docId const docId = tokenRes.body.result.docId
const res = await axios({ const res = await axiosRequest({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
headers: { headers: {
@ -94,7 +100,7 @@ module.exports = async (query, request) => {
) )
} }
const res3 = await axios({ const res3 = await axiosRequest({
method: 'put', method: 'put',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
@ -117,7 +123,7 @@ module.exports = async (query, request) => {
} }
completeStr += '</CompleteMultipartUpload>' completeStr += '</CompleteMultipartUpload>'
await axios({ await axiosRequest({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
@ -128,29 +134,29 @@ module.exports = async (query, request) => {
data: completeStr, data: completeStr,
}) })
const voiceData = JSON.stringify([
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs ? query.composedSongs.split(',') : [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
])
await request( await request(
`/api/voice/workbench/voice/batch/upload/preCheck`, `/api/voice/workbench/voice/batch/upload/preCheck`,
{ {
dupkey: createDupkey(), dupkey: createDupkey(),
voiceData: JSON.stringify([ voiceData,
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
}, },
{ {
...createOption(query), ...createOption(query),
@ -163,25 +169,7 @@ module.exports = async (query, request) => {
`/api/voice/workbench/voice/batch/upload/v2`, `/api/voice/workbench/voice/batch/upload/v2`,
{ {
dupkey: createDupkey(), dupkey: createDupkey(),
voiceData: JSON.stringify([ voiceData,
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
}, },
{ {
...createOption(query), ...createOption(query),

View File

@ -4371,27 +4371,33 @@ ONLINE 已发布
### 播客上传声音 ### 播客上传声音
说明: 可以上传声音到播客,例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a> 说明: 登录后调用此接口,使用`'Content-Type': 'multipart/form-data'`上传声音文件 formData(name 为`songFile`),可通过 formData(name 为`imgFile`)同时上传声音封面。例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a>
**接口地址:** `/voice/upload` **接口地址:** `/voice/upload`
**必选参数:** **必选参数:**
`voiceListId`: 播客 id
`coverImgId`: 播客封面 `songFile`: 声音文件
`voiceListId`: 播客 id
`categoryId`: 分类 id `categoryId`: 分类 id
`secondCategoryId`:次级分类 id `secondCategoryId`: 次级分类 id
`description`: 声音介绍 `description`: 声音介绍
**可选参数:** **可选参数:**
`imgFile`: 声音封面图片文件,上传后会自动生成图片 id。与`coverImgId`同时传入时,优先使用`imgFile`
`coverImgId`: 已上传的声音封面图片 id,未传入`imgFile`时使用该值
`songName`: 声音名称 `songName`: 声音名称
`privacy`: 设为隐私声音,播客如果是隐私博客,则必须设为 1 `privacy`: 设为隐私声音,播客如果是隐私客,则必须设为 1
`publishTime`:默认立即发布,定时发布的话需传入时间戳 `publishTime`: 默认立即发布,定时发布的话需传入时间戳
`autoPublish`: 是否发布动态,是则传入 1 `autoPublish`: 是否发布动态,是则传入 1

View File

@ -217,8 +217,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>选择文件</label> <label for="songFile">选择声音文件</label>
<input type="file" name="songFile" accept="audio/*" /> <input id="songFile" type="file" name="songFile" accept="audio/*" />
</div>
<div class="form-group">
<label for="imgFile">选择声音封面(可选)</label>
<input id="imgFile" type="file" name="imgFile" accept="image/*" />
</div> </div>
<button class="btn" @click="submit">上传</button> <button class="btn" @click="submit">上传</button>
@ -251,29 +256,36 @@
methods: { methods: {
submit() { submit() {
console.info('submit') console.info('submit')
const file = document.querySelector('input[type=file]').files[0] const songFile = document.querySelector('input[name=songFile]').files[0]
if (!file) { const imgFile = document.querySelector('input[name=imgFile]').files[0]
alert('请选择文件') if (!songFile) {
alert('请选择声音文件')
return return
} }
this.upload(file) this.upload(songFile, imgFile)
}, },
async getData() { async getData() {
this.loading = true this.loading = true
try { try {
const res = await axios({ const res = await axios({
url: `/voicelist/search?cookie=${localStorage.getItem('cookie')}`, url: `/voicelist/my/created?limit=100&cookie=${localStorage.getItem(
'cookie',
)}`,
}) })
console.info(res.data.data) console.info(res.data.data)
this.voicelist = res.data.data.list || [] this.voicelist = res.data.data.data || []
this.voicelist.forEach(async (i) => { this.voicelist.forEach(async (i) => {
try { try {
const res2 = await axios({ const res2 = await axios({
url: `/voicelist/list?voiceListId=${i.voiceListId}&limit=5`, url: `/voicelist/list?voiceListId=${
i.voiceListId
}&limit=5&cookie=${localStorage.getItem('cookie')}`,
}) })
i.voiceListData = res2.data.data.list || [] i.voiceListData = res2.data.data
? res2.data.data.list || []
: []
console.info(res2) console.info(res2)
} catch (err) { } catch (err) {
console.error('获取播客详情失败:', err) console.error('获取播客详情失败:', err)
@ -286,14 +298,17 @@
} }
}, },
upload(file) { upload(songFile, imgFile) {
if (!this.currentVoice) { if (!this.currentVoice) {
alert('请先选择播客列表') alert('请先选择播客列表')
return return
} }
var formData = new FormData() var formData = new FormData()
formData.append('songFile', file) formData.append('songFile', songFile)
if (imgFile) {
formData.append('imgFile', imgFile)
}
axios({ axios({
method: 'post', method: 'post',
@ -312,7 +327,7 @@
data: formData, data: formData,
}) })
.then((res) => { .then((res) => {
alert(`${file.name} 上传成功`) alert(`${songFile.name} 上传成功`)
}) })
.catch((err) => { .catch((err) => {
console.error('上传失败:', err) console.error('上传失败:', err)

129
test/voice_upload.test.js Normal file
View File

@ -0,0 +1,129 @@
const assert = require('assert')
const voiceUpload = require('../module/voice_upload')
function createVoiceUploadHarness(uploadPlugin) {
const requestCalls = []
return {
requestCalls,
dependencies: {
uploadPlugin,
axios: async (options) => {
if (options.method === 'post' && options.url.endsWith('?uploads')) {
return {
data: '<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>',
}
}
if (options.method === 'put') {
return { headers: { etag: 'etag-1' } }
}
return { data: {} }
},
},
request: async (uri, data, options) => {
requestCalls.push({ uri, data, options })
if (uri === '/api/nos/token/alloc') {
return {
body: {
result: {
objectKey: 'voice/audio.mp3',
docId: 'audio-doc-id',
token: 'nos-token',
},
},
}
}
if (uri === '/api/voice/workbench/voice/batch/upload/v2') {
return { body: { data: { voiceId: 'voice-id' } } }
}
return { body: { code: 200 } }
},
}
}
function createVoiceUploadQuery(overrides = {}) {
const data = Buffer.from('audio')
return {
songFile: {
name: 'episode.mp3',
mimetype: 'audio/mpeg',
size: data.length,
data,
},
voiceListId: 'voice-list-id',
categoryId: 'category-id',
secondCategoryId: 'second-category-id',
description: 'episode description',
...overrides,
}
}
describe('voice upload cover', () => {
it('uses the uploaded image as the cover for every voice submission', async () => {
let uploadedQuery
const harness = createVoiceUploadHarness(async (query) => {
uploadedQuery = query
return { imgId: 'uploaded-cover-id' }
})
const query = createVoiceUploadQuery({
imgFile: {
name: 'cover.jpg',
mimetype: 'image/jpeg',
data: Buffer.from('image'),
},
coverImgId: 'fallback-cover-id',
})
const result = await voiceUpload(
query,
harness.request,
harness.dependencies,
)
const voiceCalls = harness.requestCalls.filter((call) =>
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
)
assert.strictEqual(uploadedQuery, query)
assert.strictEqual(voiceCalls.length, 2)
voiceCalls.forEach((call) => {
const [voiceData] = JSON.parse(call.data.voiceData)
assert.strictEqual(voiceData.coverImgId, 'uploaded-cover-id')
})
assert.deepStrictEqual(result, {
status: 200,
body: {
code: 200,
data: { voiceId: 'voice-id' },
},
})
})
it('keeps using coverImgId when no image file is uploaded', async () => {
let uploaded = false
const harness = createVoiceUploadHarness(async () => {
uploaded = true
return { imgId: 'unexpected-cover-id' }
})
await voiceUpload(
createVoiceUploadQuery({ coverImgId: 'existing-cover-id' }),
harness.request,
harness.dependencies,
)
const voiceCalls = harness.requestCalls.filter((call) =>
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
)
assert.strictEqual(uploaded, false)
voiceCalls.forEach((call) => {
const [voiceData] = JSON.parse(call.data.voiceData)
assert.strictEqual(voiceData.coverImgId, 'existing-cover-id')
})
})
})