diff --git a/README.md b/README.md index 364f9448d..b071bcd2f 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,12 @@ FlyBot is the upstream [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) core, k and tested against DeepSeek Harness `0.2.1-rc.2`, the version the commands above pin. The npm package keeps the upstream name `ds-bot`, while everything you see in the app says FlyBot. +## Community + +Questions, ideas and bug reports are welcome in the FlyBot WeChat group. The code expires every few days — ask for a fresh one in [Issues](https://github.com/ThinkFlowLab/FlyBot/issues) if it no longer works. + +![WeChat group QR code for FlyBot](docs/assets/wechat-group.png) + ## License [Apache License 2.0](LICENSE). Third-party notices in [NOTICE](NOTICE). diff --git a/README.zh.md b/README.zh.md index 089641350..a0565cd02 100644 --- a/README.zh.md +++ b/README.zh.md @@ -150,6 +150,12 @@ FlyBot 是上游 [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) 的核心, 里,加上一组简短的补丁(`patches/` → `core/`)和 `plugins/` 下的插件。开发和测试针对 DeepSeek Harness `0.2.1-rc.2`,也就是上面命令钉住的版本。npm 包名沿用上游的 `ds-bot`,而你在界面里看到的都是 FlyBot。 +## 社区 + +FlyBot 的微信交流群:问题、想法和反馈都可以在这里聊。二维码约 7 天过期,过期后可以在 [Issues](https://github.com/ThinkFlowLab/FlyBot/issues) 里要一张新的。 + +![FlyBot 微信社区群二维码](docs/assets/wechat-group.png) + ## 许可 [Apache License 2.0](LICENSE)。第三方声明见 [NOTICE](NOTICE)。 diff --git a/changelog.d/775-community-qr.added.md b/changelog.d/775-community-qr.added.md new file mode 100644 index 000000000..98abcf040 --- /dev/null +++ b/changelog.d/775-community-qr.added.md @@ -0,0 +1 @@ +FlyBot has a community WeChat group; the README now carries its QR code, with a note that the code expires and how to ask for a fresh one ([#775](https://github.com/ThinkFlowLab/FlyBot/issues/775)). diff --git a/docs/assets/wechat-group.png b/docs/assets/wechat-group.png new file mode 100644 index 000000000..2c7ba7147 Binary files /dev/null and b/docs/assets/wechat-group.png differ diff --git a/package.json b/package.json index 7b80f69b7..4b4e29b8f 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,10 @@ "demo:interop": "node --import ./dev/interop/no-network.mjs dev/interop/demo.mjs", "lint:live-e2e": "node scripts/lint-live-e2e.mjs", "test:live-e2e": "node --test \"dev/live-e2e/test/*.test.mjs\"" + }, + "devDependencies": { + "jpeg-js": "^0.4.4", + "jsqr": "^1.4.0", + "pngjs": "^7.0.0" } } diff --git a/scripts/community-qr.mjs b/scripts/community-qr.mjs new file mode 100644 index 000000000..988ab0ab7 --- /dev/null +++ b/scripts/community-qr.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// Cuts the community WeChat group QR code out of the owner's photo and writes +// docs/assets/wechat-group.png: the code, a white quiet zone on every side, and nothing +// else. The source photo is a DSH attachment on the Bot machine; the PNG is committed +// once and regenerated only when WeChat rotates the invite. +// +// node scripts/community-qr.mjs [source] [out] +// +// Everything outside the code's quad is replaced with white, so no chat header, avatar, +// nickname or message preview from the photo can reach the committed file. pngjs writes +// no text chunks, so no EXIF or other metadata travels either. +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import jpeg from 'jpeg-js' +import jsQR from 'jsqr' +import { PNG } from 'pngjs' + +const DEFAULT_SOURCE = '/data/dsh/attachments/v1/objects/bd/bdc77c709327f813556c2c685d907f351f16a624da9173ed90a06c698b9082df' +const DEFAULT_OUT = 'docs/assets/wechat-group.png' +const QUIET = 0.2 // white quiet zone per side, as a fraction of the code's edge length +const CODE_PX = 480 // the code's edge in the committed file; 794 px of photo modules does not decode in jsQR + +const source = process.argv[2] || DEFAULT_SOURCE +const out = process.argv[3] || DEFAULT_OUT + +const photo = jpeg.decode(readFileSync(source), { useTArray: true }) +const code = jsQR(new Uint8ClampedArray(photo.data), photo.width, photo.height, { inversionAttempts: 'attemptBoth' }) +if (!code) throw new Error(`no QR code found in ${source}`) + +const { topLeftCorner: a, topRightCorner: b, bottomRightCorner: c, bottomLeftCorner: d } = code.location +const edge = (Math.hypot(b.x - a.x, b.y - a.y) + + Math.hypot(c.x - b.x, c.y - b.y) + + Math.hypot(d.x - c.x, d.y - c.y) + + Math.hypot(a.x - d.x, a.y - d.y)) / 4 +const margin = Math.round(edge * QUIET) + +const left = Math.floor(Math.min(a.x, b.x, c.x, d.x)) +const top = Math.floor(Math.min(a.y, b.y, c.y, d.y)) +const right = Math.ceil(Math.max(a.x, b.x, c.x, d.x)) +const bottom = Math.ceil(Math.max(a.y, b.y, c.y, d.y)) +const cropW = right - left + 2 * margin +const cropH = bottom - top + 2 * margin + +// Keep only pixels inside the code quad (grown by 1.5% so the outermost modules and the +// anti-aliasing on the edge survive); every other pixel of the box stays white. +const centre = { x: (a.x + c.x) / 2, y: (a.y + c.y) / 2 } +const quad = [a, b, c, d].map((p) => ({ x: p.x + (p.x - centre.x) * 0.015, y: p.y + (p.y - centre.y) * 0.015 })) +function inside(x, y) { + let sign = 0 + for (let i = 0; i < 4; i += 1) { + const p = quad[i] + const q = quad[(i + 1) % 4] + const cross = (q.x - p.x) * (y - p.y) - (q.y - p.y) * (x - p.x) + if (cross === 0) continue + const s = cross > 0 ? 1 : -1 + if (sign === 0) sign = s + else if (s !== sign) return false + } + return true +} +const crop = new Uint8ClampedArray(cropW * cropH * 4) +crop.fill(255) +for (let y = 0; y < cropH; y += 1) { + const sy = top - margin + y + for (let x = 0; x < cropW; x += 1) { + const sx = left - margin + x + if (sx < 0 || sy < 0 || sx >= photo.width || sy >= photo.height) continue + if (!inside(sx + 0.5, sy + 0.5)) continue + const from = (sy * photo.width + sx) * 4 + const to = (y * cropW + x) * 4 + crop[to] = photo.data[from] + crop[to + 1] = photo.data[from + 1] + crop[to + 2] = photo.data[from + 2] + } +} + +// Box-filter down to a module size jsQR can still read (the 794 px crop does not decode at +// 1:1; 0.4x-0.6x does). A phone reads this size just as well and is the same code. +const scale = CODE_PX / edge +const width = Math.round(cropW * scale) +const height = Math.round(cropH * scale) +const png = new PNG({ width, height }) +for (let y = 0; y < height; y += 1) { + const y0 = Math.floor(y / scale) + const y1 = Math.min(cropH, Math.ceil((y + 1) / scale)) + for (let x = 0; x < width; x += 1) { + const x0 = Math.floor(x / scale) + const x1 = Math.min(cropW, Math.ceil((x + 1) / scale)) + let r = 0 + let g = 0 + let bl = 0 + let n = 0 + for (let yy = y0; yy < y1; yy += 1) { + for (let xx = x0; xx < x1; xx += 1) { + const i = (yy * cropW + xx) * 4 + r += crop[i] + g += crop[i + 1] + bl += crop[i + 2] + n += 1 + } + } + const to = (y * width + x) * 4 + png.data[to] = Math.round(r / n) + png.data[to + 1] = Math.round(g / n) + png.data[to + 2] = Math.round(bl / n) + png.data[to + 3] = 255 + } +} + +// The committed file must itself decode to the same invite, not only the photo it came from. +const check = jsQR(new Uint8ClampedArray(png.data), width, height, { inversionAttempts: 'attemptBoth' }) +if (!check || check.data !== code.data) throw new Error('the cropped PNG does not decode to the same invite') + +mkdirSync(dirname(out), { recursive: true }) +const bytes = PNG.sync.write(png, { colorType: 6, inputColorType: 6 }) +writeFileSync(out, bytes) + +console.log(`payload: ${code.data}`) +console.log(`size: ${width}x${height}`) +console.log(`black modules per QR: decoded in jsQR at ${width}x${height} (code edge ${CODE_PX}px)`) +console.log(`sha256: ${createHash('sha256').update(bytes).digest('hex')}`)