├── .github
└── workflows
│ └── docker-publish.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── app
└── api_server.py
├── notify.py
├── requirements.txt
├── telecom_class.py
└── telecom_monitor.py
/.github/workflows/docker-publish.yml:
--------------------------------------------------------------------------------
1 | name: Docker Publish
2 |
3 | permissions:
4 | contents: read
5 | packages: write
6 |
7 | on:
8 | workflow_dispatch:
9 | push:
10 | branches:
11 | - main
12 | tags:
13 | - "v*"
14 |
15 | env:
16 | IMAGE_NAME: ${{ github.repository }}
17 |
18 | jobs:
19 | build-and-push:
20 | runs-on: ubuntu-latest
21 | steps:
22 | - name: Checkout repository
23 | uses: actions/checkout@v4
24 |
25 | - name: Extract metadata (tags, labels) for Docker
26 | id: meta
27 | uses: docker/metadata-action@v5
28 | with:
29 | images: |
30 | ${{ env.IMAGE_NAME }}
31 | ghcr.io/${{ env.IMAGE_NAME }}
32 | tags: |
33 | type=ref,event=branch
34 | type=ref,event=tag
35 |
36 | - name: Get version
37 | id: get_version
38 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
39 |
40 | - name: Set up QEMU
41 | uses: docker/setup-qemu-action@v3
42 |
43 | - name: Set up Docker Buildx
44 | uses: docker/setup-buildx-action@v3
45 |
46 | - name: Login to GitHub Container Registry
47 | uses: docker/login-action@v3
48 | with:
49 | registry: ghcr.io
50 | username: ${{ github.repository_owner }}
51 | password: ${{ secrets.GITHUB_TOKEN }}
52 |
53 | - name: Login to Docker Hub
54 | uses: docker/login-action@v3
55 | with:
56 | username: ${{ secrets.DOCKERHUB_USERNAME }}
57 | password: ${{ secrets.DOCKERHUB_TOKEN }}
58 |
59 | - name: Build and push Docker image
60 | uses: docker/build-push-action@v5
61 | with:
62 | build-args: |
63 | MAINTAINER=${{ github.repository_owner }}
64 | BRANCH=${{ github.ref_name }}
65 | BUILD_SHA=${{ github.sha }}
66 | BUILD_TAG=${{ steps.get_version.outputs.VERSION }}
67 | context: .
68 | platforms: |
69 | linux/amd64
70 | linux/arm64
71 | file: ./Dockerfile
72 | push: true
73 | tags: ${{ steps.meta.outputs.tags }}
74 | labels: ${{ steps.meta.outputs.labels }}
75 |
76 | - name: Update repo description
77 | uses: peter-evans/dockerhub-description@v4
78 | with:
79 | username: ${{ secrets.DOCKERHUB_USERNAME }}
80 | password: ${{ secrets.DOCKERHUB_TOKEN }}
81 | repository: ${{ env.IMAGE_NAME }}
82 | short-description: ${{ github.event.repository.description }}
83 | enable-url-completion: true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | telecom_config*
3 | config
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 使用官方 Python 镜像作为基础镜像
2 | FROM python:3.13-alpine
3 |
4 | # 设置工作目录
5 | WORKDIR /app
6 |
7 | # 将当前目录中的文件添加到工作目录中
8 | COPY . /app
9 |
10 | # 安装依赖
11 | RUN pip install --no-cache-dir -r requirements.txt \
12 | && pip install --no-cache-dir flask
13 |
14 | # 时区
15 | ENV TZ="Asia/Shanghai"
16 |
17 | # 构建版本
18 | ARG BUILD_SHA
19 | ARG BUILD_TAG
20 | ENV BUILD_SHA=$BUILD_SHA
21 | ENV BUILD_TAG=$BUILD_TAG
22 |
23 | ENV WHITELIST_NUM=
24 |
25 | # 端口
26 | EXPOSE 10000
27 |
28 | # 运行应用程序
29 | CMD ["python", "./app/api_server.py"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ChinaTelecomMonitor
2 |
3 | 中国电信 话费、通话、流量 套餐用量监控。
4 |
5 | 本项目是部署在服务器(或x86软路由等设备)使用接口模拟登录,定时获取电信手机话费、通话、流量使用情况,推送到各种通知渠道提醒。
6 |
7 | ## 特性
8 |
9 | - [x] 支持青龙
10 | - [x] 支持通过 json push_config 字段独立配置通知渠道
11 | - [x] 本地保存登录 token ,有效期内不重复登录
12 | - [x] Docker 独立部署 API 查询服务
13 |
14 | ## 使用案例
15 |
16 | - [提供一个ios的自制UI面板](https://github.com/Cp0204/ChinaTelecomMonitor/issues/18) --- By: LRZ9712
17 |
18 | ## 部署
19 |
20 | ### 青龙监控
21 |
22 | 拉库命令:
23 |
24 | ```
25 | ql repo https://github.com/Cp0204/ChinaTelecomMonitor.git "telecom_monitor" "" "telecom_class|notify"
26 | ```
27 |
28 | | 环境变量 | 示例 | 备注 |
29 | | -------------- | --------------------- | ------------------------------ |
30 | | `TELECOM_USER` | `18912345678password` | 手机号密码直接拼接,会自动截取 |
31 |
32 | ### Docker API 服务
33 |
34 | 注意:Docker 部署的是 API 服务,没有监控提醒功能,主要是用于第三方(如 HomeAssistant 等)获取信息,数据原样返回。
35 |
36 | ```shell
37 | docker run -d \
38 | --name china-telecom-monitor \
39 | -p 10000:10000 \
40 | -v ./china-telecom-monitor/config:/app/config \
41 | -v /etc/localtime:/etc/localtime \
42 | -e WHITELIST_NUM= \
43 | --network bridge \
44 | --restart unless-stopped \
45 | cp0204/chinatelecommonitor:main
46 | ```
47 |
48 | | 环境变量 | 示例 | 备注 |
49 | | --------------- | ------------------------- | ------------ |
50 | | `WHITELIST_NUM` | `18912345678,13312345678` | 手机号白名单 |
51 |
52 | #### 接口URL
53 |
54 | - `http://127.0.0.1:10000/login`
55 |
56 | 登录,返回用户信息,token长期有效,用以下次请求数据
57 |
58 | - `http://127.0.0.1:10000/qryImportantData`
59 |
60 | 返回主要信息,总用量 话费、通话、流量 等
61 |
62 | - `http://127.0.0.1:10000/userFluxPackage`
63 |
64 | 返回流量包明细
65 |
66 | - `http://127.0.0.1:10000/qryShareUsage`
67 |
68 | 返回共享套餐各号码用量
69 |
70 | - `http://127.0.0.1:10000/summary`
71 |
72 | `/qryImportantData` 的数据简化接口,非原样返回,简化后返回格式:
73 |
74 | ```json
75 | {
76 | "phonenum": "18912345678", // 手机号码
77 | "balance": 0, // 账户余额(分)
78 | "voiceUsage": 39, // 语音通话已使用时长(分钟)
79 | "voiceTotal": 2250, // 语音通话总时长(分钟)
80 | "flowUse": 7366923, // 总流量已使用量(KB)
81 | "flowTotal": 7366923, // 总流量总量(KB)
82 | "flowOver": 222222, // 总流量超量(KB)
83 | "commonUse": 7273962, // 通用流量已使用量(KB)
84 | "commonTotal": 25550446, // 通用流量总量(KB)
85 | "commonOver": 222222, // 通用流量超量(KB)
86 | "specialUse": 92961, // 专用流量已使用量(KB)
87 | "specialTotal": 215265280, // 专用流量总量(KB)
88 | "createTime": "2024-05-12 14:13:28", // 数据创建时间
89 | "flowItems": [ // 流量类型列表
90 | {
91 | "name": "国内通用流量(达量降速)", // 流量类型名称
92 | "use": 10241024, // 流量包已使用量(KB)
93 | "balance": 0, // 流量包剩余量(KB),当为负值时则是超流量
94 | "total": 10241024 // 流量包总量(KB)
95 | },
96 | {
97 | "name": "国内通用流量(非畅享)",
98 | "use": 1,
99 | "balance": 10241023,
100 | "total": 10241024
101 | },
102 | {
103 | "name": "专用流量",
104 | "use": 1,
105 | "balance": 10241023,
106 | "total": 10241024
107 | }
108 | ]
109 | }
110 | ```
111 |
112 | 接口均支持 POST 和 GET 方法,如 GET :
113 |
114 | ```
115 | http://127.0.0.1:10000/summary?phonenum=18912345678&password=123456
116 | ```
117 |
118 | POST 时 Body 须为 json 数据,如:
119 |
120 | ```bash
121 | curl --request POST \
122 | --url http://127.0.0.1:10000/summary \
123 | --header 'Content-Type: application/json' \
124 | --data '{"phonenum": "18912345678","password": "123456"}'
125 | ```
126 |
127 | > [!NOTE]
128 | > 登录成功后,会在 config/login_info.json 文件**记录账号敏感信息**。程序请求数据将先尝试用记录的 token 获取,避免重复登录。
129 |
130 | ## 感谢
131 |
132 | 本项目大量参考其他项目的代码,在此表示感谢!
133 |
134 | - [ChinaTelecomMonitor](https://github.com/LambdaExpression/ChinaTelecomMonitor) : go 语言的实现
135 | - [boxjs](https://github.com/gsons/boxjs) : 感谢开源提供的电信接口
136 |
--------------------------------------------------------------------------------
/app/api_server.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # _*_ coding:utf-8 _*_
3 |
4 | import os
5 | import sys
6 | import json
7 | from datetime import datetime
8 | from flask import Flask, request, jsonify
9 |
10 | # 导入父目录的依赖
11 | current_dir = os.path.dirname(os.path.abspath(__file__))
12 | parent_dir = os.path.dirname(current_dir)
13 | sys.path.append(parent_dir)
14 | from telecom_class import Telecom
15 |
16 | telecom = Telecom()
17 |
18 | app = Flask(__name__)
19 | app.json.ensure_ascii = False
20 | app.json.sort_keys = False
21 |
22 | # 登录信息存储文件
23 | LOGIN_INFO_FILE = os.environ.get("CONFIG_PATH", "./config/login_info.json")
24 |
25 |
26 | def load_login_info():
27 | """加载本地登录信息"""
28 | try:
29 | with open(LOGIN_INFO_FILE, "r", encoding="utf-8") as f:
30 | return json.load(f)
31 | except FileNotFoundError:
32 | return {}
33 |
34 |
35 | def save_login_info(login_info):
36 | """保存登录信息到本地"""
37 | with open(LOGIN_INFO_FILE, "w", encoding="utf-8") as f:
38 | json.dump(login_info, f, ensure_ascii=False, indent=2)
39 |
40 |
41 | @app.route("/login", methods=["POST", "GET"])
42 | def login():
43 | """登录接口"""
44 | if request.method == "POST":
45 | data = request.get_json() or {}
46 | else:
47 | data = request.args
48 | phonenum = data.get("phonenum")
49 | password = data.get("password")
50 | if not phonenum or not password:
51 | return jsonify({"message": "手机号和密码不能为空"}), 400
52 | elif whitelist_num := os.environ.get("WHITELIST_NUM"):
53 | if not phonenum in whitelist_num:
54 | return jsonify({"message": "手机号不在白名单"}), 400
55 |
56 | login_info = load_login_info()
57 | data = telecom.do_login(phonenum, password)
58 | if data.get("responseData").get("resultCode") == "0000":
59 | login_info[phonenum] = data["responseData"]["data"]["loginSuccessResult"]
60 | login_info[phonenum]["password"] = password
61 | login_info[phonenum]["createTime"] = datetime.now().strftime(
62 | "%Y-%m-%d %H:%M:%S"
63 | )
64 | save_login_info(login_info)
65 | return jsonify(data), 200
66 | else:
67 | return jsonify(data), 400
68 |
69 |
70 | def query_data(query_func, **kwargs):
71 | """
72 | 查询数据,如果本地没有登录信息或密码不匹配,则尝试登录后再查询
73 | """
74 | if request.method == "POST":
75 | data = request.get_json() or {}
76 | else:
77 | data = request.args
78 | phonenum = data.get("phonenum")
79 | password = data.get("password")
80 |
81 | login_info = load_login_info()
82 | if phonenum in login_info and login_info[phonenum]["password"] == password:
83 | telecom.set_login_info(login_info[phonenum])
84 | data = query_func(**kwargs)
85 | if data.get("responseData"):
86 | return jsonify(data), 200
87 | # 重新登录
88 | login_data, status_code = login()
89 | login_data = json.loads(login_data.data)
90 | if status_code == 200:
91 | telecom.set_login_info(login_data["responseData"]["data"]["loginSuccessResult"])
92 | data = query_func(**kwargs)
93 | if data:
94 | return jsonify(data), 200
95 | else:
96 | return jsonify(data), 400
97 | else:
98 | return jsonify(login_data), 400
99 |
100 |
101 | @app.route("/qryImportantData", methods=["POST", "GET"])
102 | def qry_important_data():
103 | """查询重要数据接口"""
104 | return query_data(telecom.qry_important_data)
105 |
106 |
107 | @app.route("/userFluxPackage", methods=["POST", "GET"])
108 | def user_flux_package():
109 | """查询流量包接口"""
110 | return query_data(telecom.user_flux_package)
111 |
112 |
113 | @app.route("/qryShareUsage", methods=["POST", "GET"])
114 | def qry_share_usage():
115 | """查询共享用量接口"""
116 | if request.method == "POST":
117 | data = request.get_json() or {}
118 | else:
119 | data = request.args
120 | return query_data(telecom.qry_share_usage, billing_cycle=data.get("billing_cycle"))
121 |
122 |
123 | @app.route("/summary", methods=["POST", "GET"])
124 | def summary():
125 | """查询重要数据简化接口"""
126 | important_data, status_code = query_data(telecom.qry_important_data)
127 | print(important_data.data)
128 | if status_code == 200:
129 | data = telecom.to_summary(
130 | json.loads(important_data.data)["responseData"]["data"]
131 | )
132 | return jsonify(data), 200
133 |
134 |
135 | if __name__ == "__main__":
136 | app.run(debug=os.environ.get("DEBUG", False), host="0.0.0.0", port=10000)
137 |
--------------------------------------------------------------------------------
/notify.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # _*_ coding:utf-8 _*_
3 | import base64
4 | import hashlib
5 | import hmac
6 | import json
7 | import os
8 | import re
9 | import threading
10 | import time
11 | import urllib.parse
12 | import smtplib
13 | from email.mime.text import MIMEText
14 | from email.header import Header
15 | from email.utils import formataddr
16 |
17 | import requests
18 |
19 | # 原先的 print 函数和主线程的锁
20 | _print = print
21 | mutex = threading.Lock()
22 |
23 |
24 | # 定义新的 print 函数
25 | def print(text, *args, **kw):
26 | """
27 | 使输出有序进行,不出现多线程同一时间输出导致错乱的问题。
28 | """
29 | with mutex:
30 | _print(text, *args, **kw)
31 |
32 |
33 | # 通知服务
34 | # fmt: off
35 | push_config = {
36 | 'HITOKOTO': False, # 启用一言(随机句子)
37 |
38 | 'BARK_PUSH': '', # bark IP 或设备码,例:https://api.day.app/DxHcxxxxxRxxxxxxcm/
39 | 'BARK_ARCHIVE': '', # bark 推送是否存档
40 | 'BARK_GROUP': '', # bark 推送分组
41 | 'BARK_SOUND': '', # bark 推送声音
42 | 'BARK_ICON': '', # bark 推送图标
43 | 'BARK_LEVEL': '', # bark 推送时效性
44 | 'BARK_URL': '', # bark 推送跳转URL
45 |
46 | 'CONSOLE': False, # 控制台输出
47 |
48 | 'DD_BOT_SECRET': '', # 钉钉机器人的 DD_BOT_SECRET
49 | 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
50 |
51 | 'FSKEY': '', # 飞书机器人的 FSKEY
52 |
53 | 'GOBOT_URL': '', # go-cqhttp
54 | # 推送到个人QQ:http://127.0.0.1/send_private_msg
55 | # 群:http://127.0.0.1/send_group_msg
56 | 'GOBOT_QQ': '', # go-cqhttp 的推送群或用户
57 | # GOBOT_URL 设置 /send_private_msg 时填入 user_id=个人QQ
58 | # /send_group_msg 时填入 group_id=QQ群
59 | 'GOBOT_TOKEN': '', # go-cqhttp 的 access_token
60 |
61 | 'GOTIFY_URL': '', # gotify地址,如https://push.example.de:8080
62 | 'GOTIFY_TOKEN': '', # gotify的消息应用token
63 | 'GOTIFY_PRIORITY': 0, # 推送消息优先级,默认为0
64 |
65 | 'IGOT_PUSH_KEY': '', # iGot 聚合推送的 IGOT_PUSH_KEY
66 |
67 | 'PUSH_KEY': '', # server 酱的 PUSH_KEY,兼容旧版与 Turbo 版
68 |
69 | 'DEER_KEY': '', # PushDeer 的 PUSHDEER_KEY
70 | 'DEER_URL': '', # PushDeer 的 PUSHDEER_URL
71 |
72 | 'CHAT_URL': '', # synology chat url
73 | 'CHAT_TOKEN': '', # synology chat token
74 |
75 | 'PUSH_PLUS_TOKEN': '', # push+ 微信推送的用户令牌
76 | 'PUSH_PLUS_USER': '', # push+ 微信推送的群组编码
77 |
78 | 'WE_PLUS_BOT_TOKEN': '', # 微加机器人的用户令牌
79 | 'WE_PLUS_BOT_RECEIVER': '', # 微加机器人的消息接收者
80 | 'WE_PLUS_BOT_VERSION': 'pro', # 微加机器人的调用版本
81 |
82 | 'QMSG_KEY': '', # qmsg 酱的 QMSG_KEY
83 | 'QMSG_TYPE': '', # qmsg 酱的 QMSG_TYPE
84 |
85 | 'QYWX_ORIGIN': '', # 企业微信代理地址
86 |
87 | 'QYWX_AM': '', # 企业微信应用
88 |
89 | 'QYWX_KEY': '', # 企业微信机器人
90 |
91 | 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ
92 | 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534
93 | 'TG_API_HOST': '', # tg 代理 api
94 | 'TG_PROXY_AUTH': '', # tg 代理认证参数
95 | 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST
96 | 'TG_PROXY_PORT': '', # tg 机器人的 TG_PROXY_PORT
97 |
98 | 'AIBOTK_KEY': '', # 智能微秘书 个人中心的apikey 文档地址:http://wechat.aibotk.com/docs/about
99 | 'AIBOTK_TYPE': '', # 智能微秘书 发送目标 room 或 contact
100 | 'AIBOTK_NAME': '', # 智能微秘书 发送群名 或者好友昵称和type要对应好
101 |
102 | 'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465
103 | 'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false
104 | 'SMTP_EMAIL': '', # SMTP 发件邮箱
105 | 'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
106 | 'SMTP_NAME': '', # SMTP 发件人姓名,可随意填写
107 | 'SMTP_EMAIL_TO': '', # SMTP 收件邮箱,可选,缺省时将自己发给自己,多个收件邮箱逗号间隔
108 | 'SMTP_NAME_TO': '', # SMTP 收件人姓名,可选,可随意填写,多个收件人逗号间隔,顺序与 SMTP_EMAIL_TO 保持一致
109 |
110 | 'PUSHME_KEY': '', # PushMe 的 PUSHME_KEY
111 | 'PUSHME_URL': '', # PushMe 的 PUSHME_URL
112 |
113 | 'CHRONOCAT_QQ': '', # qq号
114 | 'CHRONOCAT_TOKEN': '', # CHRONOCAT 的token
115 | 'CHRONOCAT_URL': '', # CHRONOCAT的url地址
116 |
117 | 'WEBHOOK_URL': '', # 自定义通知 请求地址
118 | 'WEBHOOK_BODY': '', # 自定义通知 请求体
119 | 'WEBHOOK_HEADERS': '', # 自定义通知 请求头
120 | 'WEBHOOK_METHOD': '', # 自定义通知 请求方法
121 | 'WEBHOOK_CONTENT_TYPE': '', # 自定义通知 content-type
122 |
123 | 'NTFY_URL': '', # ntfy地址,如https://ntfy.sh
124 | 'NTFY_TOPIC': '', # ntfy的消息应用topic
125 | 'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3
126 | }
127 | # fmt: on
128 |
129 | for k in push_config:
130 | if os.getenv(k):
131 | v = os.getenv(k)
132 | push_config[k] = v
133 |
134 |
135 | def bark(title: str, content: str) -> None:
136 | """
137 | 使用 bark 推送消息。
138 | """
139 | if not push_config.get("BARK_PUSH"):
140 | print("bark 服务的 BARK_PUSH 未设置!!\n取消推送")
141 | return
142 | print("bark 服务启动")
143 |
144 | if push_config.get("BARK_PUSH").startswith("http"):
145 | url = f'{push_config.get("BARK_PUSH")}'
146 | else:
147 | url = f'https://api.day.app/{push_config.get("BARK_PUSH")}'
148 |
149 | bark_params = {
150 | "BARK_ARCHIVE": "isArchive",
151 | "BARK_GROUP": "group",
152 | "BARK_SOUND": "sound",
153 | "BARK_ICON": "icon",
154 | "BARK_LEVEL": "level",
155 | "BARK_URL": "url",
156 | }
157 | data = {
158 | "title": title,
159 | "body": content,
160 | }
161 | for pair in filter(
162 | lambda pairs: pairs[0].startswith("BARK_")
163 | and pairs[0] != "BARK_PUSH"
164 | and pairs[1]
165 | and bark_params.get(pairs[0]),
166 | push_config.items(),
167 | ):
168 | data[bark_params.get(pair[0])] = pair[1]
169 | headers = {"Content-Type": "application/json;charset=utf-8"}
170 | response = requests.post(
171 | url=url, data=json.dumps(data), headers=headers, timeout=15
172 | ).json()
173 |
174 | if response["code"] == 200:
175 | print("bark 推送成功!")
176 | else:
177 | print("bark 推送失败!")
178 |
179 |
180 | def console(title: str, content: str) -> None:
181 | """
182 | 使用 控制台 推送消息。
183 | """
184 | if str(push_config.get("CONSOLE")).lower() != "false":
185 | print(f"{title}\n\n{content}")
186 |
187 |
188 | def dingding_bot(title: str, content: str) -> None:
189 | """
190 | 使用 钉钉机器人 推送消息。
191 | """
192 | if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
193 | print("钉钉机器人 服务的 DD_BOT_SECRET 或者 DD_BOT_TOKEN 未设置!!\n取消推送")
194 | return
195 | print("钉钉机器人 服务启动")
196 |
197 | timestamp = str(round(time.time() * 1000))
198 | secret_enc = push_config.get("DD_BOT_SECRET").encode("utf-8")
199 | string_to_sign = "{}\n{}".format(timestamp, push_config.get("DD_BOT_SECRET"))
200 | string_to_sign_enc = string_to_sign.encode("utf-8")
201 | hmac_code = hmac.new(
202 | secret_enc, string_to_sign_enc, digestmod=hashlib.sha256
203 | ).digest()
204 | sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
205 | url = f'https://oapi.dingtalk.com/robot/send?access_token={push_config.get("DD_BOT_TOKEN")}×tamp={timestamp}&sign={sign}'
206 | headers = {"Content-Type": "application/json;charset=utf-8"}
207 | data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
208 | response = requests.post(
209 | url=url, data=json.dumps(data), headers=headers, timeout=15
210 | ).json()
211 |
212 | if not response["errcode"]:
213 | print("钉钉机器人 推送成功!")
214 | else:
215 | print("钉钉机器人 推送失败!")
216 |
217 |
218 | def feishu_bot(title: str, content: str) -> None:
219 | """
220 | 使用 飞书机器人 推送消息。
221 | """
222 | if not push_config.get("FSKEY"):
223 | print("飞书 服务的 FSKEY 未设置!!\n取消推送")
224 | return
225 | print("飞书 服务启动")
226 |
227 | url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}'
228 | data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}}
229 | response = requests.post(url, data=json.dumps(data)).json()
230 |
231 | if response.get("StatusCode") == 0 or response.get("code") == 0:
232 | print("飞书 推送成功!")
233 | else:
234 | print("飞书 推送失败!错误信息如下:\n", response)
235 |
236 |
237 | def go_cqhttp(title: str, content: str) -> None:
238 | """
239 | 使用 go_cqhttp 推送消息。
240 | """
241 | if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
242 | print("go-cqhttp 服务的 GOBOT_URL 或 GOBOT_QQ 未设置!!\n取消推送")
243 | return
244 | print("go-cqhttp 服务启动")
245 |
246 | url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}'
247 | response = requests.get(url).json()
248 |
249 | if response["status"] == "ok":
250 | print("go-cqhttp 推送成功!")
251 | else:
252 | print("go-cqhttp 推送失败!")
253 |
254 |
255 | def gotify(title: str, content: str) -> None:
256 | """
257 | 使用 gotify 推送消息。
258 | """
259 | if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
260 | print("gotify 服务的 GOTIFY_URL 或 GOTIFY_TOKEN 未设置!!\n取消推送")
261 | return
262 | print("gotify 服务启动")
263 |
264 | url = f'{push_config.get("GOTIFY_URL")}/message?token={push_config.get("GOTIFY_TOKEN")}'
265 | data = {
266 | "title": title,
267 | "message": content,
268 | "priority": push_config.get("GOTIFY_PRIORITY"),
269 | }
270 | response = requests.post(url, data=data).json()
271 |
272 | if response.get("id"):
273 | print("gotify 推送成功!")
274 | else:
275 | print("gotify 推送失败!")
276 |
277 |
278 | def iGot(title: str, content: str) -> None:
279 | """
280 | 使用 iGot 推送消息。
281 | """
282 | if not push_config.get("IGOT_PUSH_KEY"):
283 | print("iGot 服务的 IGOT_PUSH_KEY 未设置!!\n取消推送")
284 | return
285 | print("iGot 服务启动")
286 |
287 | url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}'
288 | data = {"title": title, "content": content}
289 | headers = {"Content-Type": "application/x-www-form-urlencoded"}
290 | response = requests.post(url, data=data, headers=headers).json()
291 |
292 | if response["ret"] == 0:
293 | print("iGot 推送成功!")
294 | else:
295 | print(f'iGot 推送失败!{response["errMsg"]}')
296 |
297 |
298 | def serverJ(title: str, content: str) -> None:
299 | """
300 | 通过 serverJ 推送消息。
301 | """
302 | if not push_config.get("PUSH_KEY"):
303 | print("serverJ 服务的 PUSH_KEY 未设置!!\n取消推送")
304 | return
305 | print("serverJ 服务启动")
306 |
307 | data = {"text": title, "desp": content.replace("\n", "\n\n")}
308 |
309 | match = re.match(r'sctp(\d+)t', push_config.get("PUSH_KEY"))
310 | if match:
311 | num = match.group(1)
312 | url = f'https://{num}.push.ft07.com/send/{push_config.get("PUSH_KEY")}.send'
313 | else:
314 | url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send'
315 |
316 | response = requests.post(url, data=data).json()
317 |
318 | if response.get("errno") == 0 or response.get("code") == 0:
319 | print("serverJ 推送成功!")
320 | else:
321 | print(f'serverJ 推送失败!错误码:{response["message"]}')
322 |
323 |
324 | def pushdeer(title: str, content: str) -> None:
325 | """
326 | 通过PushDeer 推送消息
327 | """
328 | if not push_config.get("DEER_KEY"):
329 | print("PushDeer 服务的 DEER_KEY 未设置!!\n取消推送")
330 | return
331 | print("PushDeer 服务启动")
332 | data = {
333 | "text": title,
334 | "desp": content,
335 | "type": "markdown",
336 | "pushkey": push_config.get("DEER_KEY"),
337 | }
338 | url = "https://api2.pushdeer.com/message/push"
339 | if push_config.get("DEER_URL"):
340 | url = push_config.get("DEER_URL")
341 |
342 | response = requests.post(url, data=data).json()
343 |
344 | if len(response.get("content").get("result")) > 0:
345 | print("PushDeer 推送成功!")
346 | else:
347 | print("PushDeer 推送失败!错误信息:", response)
348 |
349 |
350 | def chat(title: str, content: str) -> None:
351 | """
352 | 通过Chat 推送消息
353 | """
354 | if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
355 | print("chat 服务的 CHAT_URL或CHAT_TOKEN 未设置!!\n取消推送")
356 | return
357 | print("chat 服务启动")
358 | data = "payload=" + json.dumps({"text": title + "\n" + content})
359 | url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN")
360 | response = requests.post(url, data=data)
361 |
362 | if response.status_code == 200:
363 | print("Chat 推送成功!")
364 | else:
365 | print("Chat 推送失败!错误信息:", response)
366 |
367 |
368 | def pushplus_bot(title: str, content: str) -> None:
369 | """
370 | 通过 push+ 推送消息。
371 | """
372 | if not push_config.get("PUSH_PLUS_TOKEN"):
373 | print("PUSHPLUS 服务的 PUSH_PLUS_TOKEN 未设置!!\n取消推送")
374 | return
375 | print("PUSHPLUS 服务启动")
376 |
377 | url = "http://www.pushplus.plus/send"
378 | data = {
379 | "token": push_config.get("PUSH_PLUS_TOKEN"),
380 | "title": title,
381 | "content": content,
382 | "topic": push_config.get("PUSH_PLUS_USER"),
383 | }
384 | body = json.dumps(data).encode(encoding="utf-8")
385 | headers = {"Content-Type": "application/json"}
386 | response = requests.post(url=url, data=body, headers=headers).json()
387 |
388 | if response["code"] == 200:
389 | print("PUSHPLUS 推送成功!")
390 |
391 | else:
392 | url_old = "http://pushplus.hxtrip.com/send"
393 | headers["Accept"] = "application/json"
394 | response = requests.post(url=url_old, data=body, headers=headers).json()
395 |
396 | if response["code"] == 200:
397 | print("PUSHPLUS(hxtrip) 推送成功!")
398 |
399 | else:
400 | print("PUSHPLUS 推送失败!")
401 |
402 |
403 | def weplus_bot(title: str, content: str) -> None:
404 | """
405 | 通过 微加机器人 推送消息。
406 | """
407 | if not push_config.get("WE_PLUS_BOT_TOKEN"):
408 | print("微加机器人 服务的 WE_PLUS_BOT_TOKEN 未设置!!\n取消推送")
409 | return
410 | print("微加机器人 服务启动")
411 |
412 | template = "txt"
413 | if len(content) > 800:
414 | template = "html"
415 |
416 | url = "https://www.weplusbot.com/send"
417 | data = {
418 | "token": push_config.get("WE_PLUS_BOT_TOKEN"),
419 | "title": title,
420 | "content": content,
421 | "template": template,
422 | "receiver": push_config.get("WE_PLUS_BOT_RECEIVER"),
423 | "version": push_config.get("WE_PLUS_BOT_VERSION"),
424 | }
425 | body = json.dumps(data).encode(encoding="utf-8")
426 | headers = {"Content-Type": "application/json"}
427 | response = requests.post(url=url, data=body, headers=headers).json()
428 |
429 | if response["code"] == 200:
430 | print("微加机器人 推送成功!")
431 | else:
432 | print("微加机器人 推送失败!")
433 |
434 |
435 | def qmsg_bot(title: str, content: str) -> None:
436 | """
437 | 使用 qmsg 推送消息。
438 | """
439 | if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
440 | print("qmsg 的 QMSG_KEY 或者 QMSG_TYPE 未设置!!\n取消推送")
441 | return
442 | print("qmsg 服务启动")
443 |
444 | url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}'
445 | payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")}
446 | response = requests.post(url=url, params=payload).json()
447 |
448 | if response["code"] == 0:
449 | print("qmsg 推送成功!")
450 | else:
451 | print(f'qmsg 推送失败!{response["reason"]}')
452 |
453 |
454 | def wecom_app(title: str, content: str) -> None:
455 | """
456 | 通过 企业微信 APP 推送消息。
457 | """
458 | if not push_config.get("QYWX_AM"):
459 | print("QYWX_AM 未设置!!\n取消推送")
460 | return
461 | QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
462 | if 4 < len(QYWX_AM_AY) > 5:
463 | print("QYWX_AM 设置错误!!\n取消推送")
464 | return
465 | print("企业微信 APP 服务启动")
466 |
467 | corpid = QYWX_AM_AY[0]
468 | corpsecret = QYWX_AM_AY[1]
469 | touser = QYWX_AM_AY[2]
470 | agentid = QYWX_AM_AY[3]
471 | try:
472 | media_id = QYWX_AM_AY[4]
473 | except IndexError:
474 | media_id = ""
475 | wx = WeCom(corpid, corpsecret, agentid)
476 | # 如果没有配置 media_id 默认就以 text 方式发送
477 | if not media_id:
478 | message = title + "\n\n" + content
479 | response = wx.send_text(message, touser)
480 | else:
481 | response = wx.send_mpnews(title, content, media_id, touser)
482 |
483 | if response == "ok":
484 | print("企业微信推送成功!")
485 | else:
486 | print("企业微信推送失败!错误信息如下:\n", response)
487 |
488 |
489 | class WeCom:
490 | def __init__(self, corpid, corpsecret, agentid):
491 | self.CORPID = corpid
492 | self.CORPSECRET = corpsecret
493 | self.AGENTID = agentid
494 | self.ORIGIN = "https://qyapi.weixin.qq.com"
495 | if push_config.get("QYWX_ORIGIN"):
496 | self.ORIGIN = push_config.get("QYWX_ORIGIN")
497 |
498 | def get_access_token(self):
499 | url = f"{self.ORIGIN}/cgi-bin/gettoken"
500 | values = {
501 | "corpid": self.CORPID,
502 | "corpsecret": self.CORPSECRET,
503 | }
504 | req = requests.post(url, params=values)
505 | data = json.loads(req.text)
506 | return data["access_token"]
507 |
508 | def send_text(self, message, touser="@all"):
509 | send_url = (
510 | f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}"
511 | )
512 | send_values = {
513 | "touser": touser,
514 | "msgtype": "text",
515 | "agentid": self.AGENTID,
516 | "text": {"content": message},
517 | "safe": "0",
518 | }
519 | send_msges = bytes(json.dumps(send_values), "utf-8")
520 | respone = requests.post(send_url, send_msges)
521 | respone = respone.json()
522 | return respone["errmsg"]
523 |
524 | def send_mpnews(self, title, message, media_id, touser="@all"):
525 | send_url = (
526 | f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}"
527 | )
528 | send_values = {
529 | "touser": touser,
530 | "msgtype": "mpnews",
531 | "agentid": self.AGENTID,
532 | "mpnews": {
533 | "articles": [
534 | {
535 | "title": title,
536 | "thumb_media_id": media_id,
537 | "author": "Author",
538 | "content_source_url": "",
539 | "content": message.replace("\n", "
"),
540 | "digest": message,
541 | }
542 | ]
543 | },
544 | }
545 | send_msges = bytes(json.dumps(send_values), "utf-8")
546 | respone = requests.post(send_url, send_msges)
547 | respone = respone.json()
548 | return respone["errmsg"]
549 |
550 |
551 | def wecom_bot(title: str, content: str) -> None:
552 | """
553 | 通过 企业微信机器人 推送消息。
554 | """
555 | if not push_config.get("QYWX_KEY"):
556 | print("企业微信机器人 服务的 QYWX_KEY 未设置!!\n取消推送")
557 | return
558 | print("企业微信机器人服务启动")
559 |
560 | origin = "https://qyapi.weixin.qq.com"
561 | if push_config.get("QYWX_ORIGIN"):
562 | origin = push_config.get("QYWX_ORIGIN")
563 |
564 | url = f"{origin}/cgi-bin/webhook/send?key={push_config.get('QYWX_KEY')}"
565 | headers = {"Content-Type": "application/json;charset=utf-8"}
566 | data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
567 | response = requests.post(
568 | url=url, data=json.dumps(data), headers=headers, timeout=15
569 | ).json()
570 |
571 | if response["errcode"] == 0:
572 | print("企业微信机器人推送成功!")
573 | else:
574 | print("企业微信机器人推送失败!")
575 |
576 |
577 | def telegram_bot(title: str, content: str) -> None:
578 | """
579 | 使用 telegram 机器人 推送消息。
580 | """
581 | if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
582 | print("tg 服务的 bot_token 或者 user_id 未设置!!\n取消推送")
583 | return
584 | print("tg 服务启动")
585 |
586 | if push_config.get("TG_API_HOST"):
587 | url = f"{push_config.get('TG_API_HOST')}/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage"
588 | else:
589 | url = (
590 | f"https://api.telegram.org/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage"
591 | )
592 | headers = {"Content-Type": "application/x-www-form-urlencoded"}
593 | payload = {
594 | "chat_id": str(push_config.get("TG_USER_ID")),
595 | "text": f"{title}\n\n{content}",
596 | "disable_web_page_preview": "true",
597 | }
598 | proxies = None
599 | if push_config.get("TG_PROXY_HOST") and push_config.get("TG_PROXY_PORT"):
600 | if push_config.get("TG_PROXY_AUTH") is not None and "@" not in push_config.get(
601 | "TG_PROXY_HOST"
602 | ):
603 | push_config["TG_PROXY_HOST"] = (
604 | push_config.get("TG_PROXY_AUTH")
605 | + "@"
606 | + push_config.get("TG_PROXY_HOST")
607 | )
608 | proxyStr = "http://{}:{}".format(
609 | push_config.get("TG_PROXY_HOST"), push_config.get("TG_PROXY_PORT")
610 | )
611 | proxies = {"http": proxyStr, "https": proxyStr}
612 | response = requests.post(
613 | url=url, headers=headers, params=payload, proxies=proxies
614 | ).json()
615 |
616 | if response["ok"]:
617 | print("tg 推送成功!")
618 | else:
619 | print("tg 推送失败!")
620 |
621 |
622 | def aibotk(title: str, content: str) -> None:
623 | """
624 | 使用 智能微秘书 推送消息。
625 | """
626 | if (
627 | not push_config.get("AIBOTK_KEY")
628 | or not push_config.get("AIBOTK_TYPE")
629 | or not push_config.get("AIBOTK_NAME")
630 | ):
631 | print(
632 | "智能微秘书 的 AIBOTK_KEY 或者 AIBOTK_TYPE 或者 AIBOTK_NAME 未设置!!\n取消推送"
633 | )
634 | return
635 | print("智能微秘书 服务启动")
636 |
637 | if push_config.get("AIBOTK_TYPE") == "room":
638 | url = "https://api-bot.aibotk.com/openapi/v1/chat/room"
639 | data = {
640 | "apiKey": push_config.get("AIBOTK_KEY"),
641 | "roomName": push_config.get("AIBOTK_NAME"),
642 | "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"},
643 | }
644 | else:
645 | url = "https://api-bot.aibotk.com/openapi/v1/chat/contact"
646 | data = {
647 | "apiKey": push_config.get("AIBOTK_KEY"),
648 | "name": push_config.get("AIBOTK_NAME"),
649 | "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"},
650 | }
651 | body = json.dumps(data).encode(encoding="utf-8")
652 | headers = {"Content-Type": "application/json"}
653 | response = requests.post(url=url, data=body, headers=headers).json()
654 | print(response)
655 | if response["code"] == 0:
656 | print("智能微秘书 推送成功!")
657 | else:
658 | print(f'智能微秘书 推送失败!{response["error"]}')
659 |
660 |
661 | def smtp(title: str, content: str) -> None:
662 | """
663 | 使用 SMTP 邮件 推送消息。
664 | """
665 | if (
666 | not push_config.get("SMTP_SERVER")
667 | or not push_config.get("SMTP_SSL")
668 | or not push_config.get("SMTP_EMAIL")
669 | or not push_config.get("SMTP_PASSWORD")
670 | or not push_config.get("SMTP_NAME")
671 | ):
672 | print(
673 | "SMTP 邮件 的 SMTP_SERVER 或者 SMTP_SSL 或者 SMTP_EMAIL 或者 SMTP_PASSWORD 或者 SMTP_NAME 未设置!!\n取消推送"
674 | )
675 | return
676 | print("SMTP 邮件 服务启动")
677 |
678 | message = MIMEText(content, "plain", "utf-8")
679 | message["From"] = formataddr(
680 | (
681 | Header(push_config.get("SMTP_NAME"), "utf-8").encode(),
682 | push_config.get("SMTP_EMAIL"),
683 | )
684 | )
685 | if not push_config.get("SMTP_EMAIL_TO"):
686 | smtp_email_to = push_config.get("SMTP_EMAIL")
687 | message["To"] = formataddr(
688 | (
689 | Header(push_config.get("SMTP_NAME"), "utf-8").encode(),
690 | push_config.get("SMTP_EMAIL"),
691 | )
692 | )
693 | else:
694 | smtp_email_to = push_config.get("SMTP_EMAIL_TO").split(",")
695 | smtp_name_to = push_config.get("SMTP_NAME_TO","").split(",")
696 | message["To"] = ",".join([formataddr(
697 | (
698 | Header(smtp_name_to[i] if len(smtp_name_to) > i else "", "utf-8").encode(),
699 | email_to,
700 | )
701 | ) for i, email_to in enumerate(smtp_email_to)])
702 | message["Subject"] = Header(title, "utf-8")
703 |
704 | try:
705 | smtp_server = (
706 | smtplib.SMTP_SSL(push_config.get("SMTP_SERVER"))
707 | if push_config.get("SMTP_SSL") == "true"
708 | else smtplib.SMTP(push_config.get("SMTP_SERVER"))
709 | )
710 | smtp_server.login(
711 | push_config.get("SMTP_EMAIL"), push_config.get("SMTP_PASSWORD")
712 | )
713 | smtp_server.sendmail(
714 | push_config.get("SMTP_EMAIL"),
715 | smtp_email_to,
716 | message.as_bytes(),
717 | )
718 | smtp_server.close()
719 | print("SMTP 邮件 推送成功!")
720 | except Exception as e:
721 | print(f"SMTP 邮件 推送失败!{e}")
722 |
723 |
724 | def pushme(title: str, content: str) -> None:
725 | """
726 | 使用 PushMe 推送消息。
727 | """
728 | if not push_config.get("PUSHME_KEY"):
729 | print("PushMe 服务的 PUSHME_KEY 未设置!!\n取消推送")
730 | return
731 | print("PushMe 服务启动")
732 |
733 | url = (
734 | push_config.get("PUSHME_URL")
735 | if push_config.get("PUSHME_URL")
736 | else "https://push.i-i.me/"
737 | )
738 | data = {
739 | "push_key": push_config.get("PUSHME_KEY"),
740 | "title": title,
741 | "content": content,
742 | "date": push_config.get("date") if push_config.get("date") else "",
743 | "type": push_config.get("type") if push_config.get("type") else "",
744 | }
745 | response = requests.post(url, data=data)
746 |
747 | if response.status_code == 200 and response.text == "success":
748 | print("PushMe 推送成功!")
749 | else:
750 | print(f"PushMe 推送失败!{response.status_code} {response.text}")
751 |
752 |
753 | def chronocat(title: str, content: str) -> None:
754 | """
755 | 使用 CHRONOCAT 推送消息。
756 | """
757 | if (
758 | not push_config.get("CHRONOCAT_URL")
759 | or not push_config.get("CHRONOCAT_QQ")
760 | or not push_config.get("CHRONOCAT_TOKEN")
761 | ):
762 | print("CHRONOCAT 服务的 CHRONOCAT_URL 或 CHRONOCAT_QQ 未设置!!\n取消推送")
763 | return
764 |
765 | print("CHRONOCAT 服务启动")
766 |
767 | user_ids = re.findall(r"user_id=(\d+)", push_config.get("CHRONOCAT_QQ"))
768 | group_ids = re.findall(r"group_id=(\d+)", push_config.get("CHRONOCAT_QQ"))
769 |
770 | url = f'{push_config.get("CHRONOCAT_URL")}/api/message/send'
771 | headers = {
772 | "Content-Type": "application/json",
773 | "Authorization": f'Bearer {push_config.get("CHRONOCAT_TOKEN")}',
774 | }
775 |
776 | for chat_type, ids in [(1, user_ids), (2, group_ids)]:
777 | if not ids:
778 | continue
779 | for chat_id in ids:
780 | data = {
781 | "peer": {"chatType": chat_type, "peerUin": chat_id},
782 | "elements": [
783 | {
784 | "elementType": 1,
785 | "textElement": {"content": f"{title}\n\n{content}"},
786 | }
787 | ],
788 | }
789 | response = requests.post(url, headers=headers, data=json.dumps(data))
790 | if response.status_code == 200:
791 | if chat_type == 1:
792 | print(f"QQ个人消息:{ids}推送成功!")
793 | else:
794 | print(f"QQ群消息:{ids}推送成功!")
795 | else:
796 | if chat_type == 1:
797 | print(f"QQ个人消息:{ids}推送失败!")
798 | else:
799 | print(f"QQ群消息:{ids}推送失败!")
800 |
801 |
802 | def ntfy(title: str, content: str) -> None:
803 | """
804 | 通过 Ntfy 推送消息
805 | """
806 | def encode_rfc2047(text: str) -> str:
807 | """将文本编码为符合 RFC 2047 标准的格式"""
808 | encoded_bytes = base64.b64encode(text.encode('utf-8'))
809 | encoded_str = encoded_bytes.decode('utf-8')
810 | return f'=?utf-8?B?{encoded_str}?='
811 |
812 | if not push_config.get("NTFY_TOPIC"):
813 | print("ntfy 服务的 NTFY_TOPIC 未设置!!\n取消推送")
814 | return
815 | print("ntfy 服务启动")
816 | priority = '3'
817 | if not push_config.get("NTFY_PRIORITY"):
818 | print("ntfy 服务的NTFY_PRIORITY 未设置!!默认设置为3")
819 | else:
820 | priority = push_config.get("NTFY_PRIORITY")
821 |
822 | # 使用 RFC 2047 编码 title
823 | encoded_title = encode_rfc2047(title)
824 |
825 | data = content.encode(encoding='utf-8')
826 | headers = {
827 | "Title": encoded_title, # 使用编码后的 title
828 | "Priority": priority
829 | }
830 |
831 | url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
832 | response = requests.post(url, data=data, headers=headers)
833 | if response.status_code == 200: # 使用 response.status_code 进行检查
834 | print("Ntfy 推送成功!")
835 | else:
836 | print("Ntfy 推送失败!错误信息:", response.text)
837 |
838 | def parse_headers(headers):
839 | if not headers:
840 | return {}
841 |
842 | parsed = {}
843 | lines = headers.split("\n")
844 |
845 | for line in lines:
846 | i = line.find(":")
847 | if i == -1:
848 | continue
849 |
850 | key = line[:i].strip().lower()
851 | val = line[i + 1 :].strip()
852 | parsed[key] = parsed.get(key, "") + ", " + val if key in parsed else val
853 |
854 | return parsed
855 |
856 |
857 | def parse_string(input_string, value_format_fn=None):
858 | matches = {}
859 | pattern = r"(\w+):\s*((?:(?!\n\w+:).)*)"
860 | regex = re.compile(pattern)
861 | for match in regex.finditer(input_string):
862 | key, value = match.group(1).strip(), match.group(2).strip()
863 | try:
864 | value = value_format_fn(value) if value_format_fn else value
865 | json_value = json.loads(value)
866 | matches[key] = json_value
867 | except:
868 | matches[key] = value
869 | return matches
870 |
871 |
872 | def parse_body(body, content_type, value_format_fn=None):
873 | if not body or content_type == "text/plain":
874 | return value_format_fn(body) if value_format_fn and body else body
875 |
876 | parsed = parse_string(body, value_format_fn)
877 |
878 | if content_type == "application/x-www-form-urlencoded":
879 | data = urllib.parse.urlencode(parsed, doseq=True)
880 | return data
881 |
882 | if content_type == "application/json":
883 | data = json.dumps(parsed)
884 | return data
885 |
886 | return parsed
887 |
888 |
889 | def custom_notify(title: str, content: str) -> None:
890 | """
891 | 通过 自定义通知 推送消息。
892 | """
893 | if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
894 | print("自定义通知的 WEBHOOK_URL 或 WEBHOOK_METHOD 未设置!!\n取消推送")
895 | return
896 |
897 | print("自定义通知服务启动")
898 |
899 | WEBHOOK_URL = push_config.get("WEBHOOK_URL")
900 | WEBHOOK_METHOD = push_config.get("WEBHOOK_METHOD")
901 | WEBHOOK_CONTENT_TYPE = push_config.get("WEBHOOK_CONTENT_TYPE")
902 | WEBHOOK_BODY = push_config.get("WEBHOOK_BODY")
903 | WEBHOOK_HEADERS = push_config.get("WEBHOOK_HEADERS")
904 |
905 | if "$title" not in WEBHOOK_URL and "$title" not in WEBHOOK_BODY:
906 | print("请求头或者请求体中必须包含 $title 和 $content")
907 | return
908 |
909 | headers = parse_headers(WEBHOOK_HEADERS)
910 | body = parse_body(
911 | WEBHOOK_BODY,
912 | WEBHOOK_CONTENT_TYPE,
913 | lambda v: v.replace("$title", title.replace("\n", "\\n")).replace(
914 | "$content", content.replace("\n", "\\n")
915 | ),
916 | )
917 | formatted_url = WEBHOOK_URL.replace(
918 | "$title", urllib.parse.quote_plus(title)
919 | ).replace("$content", urllib.parse.quote_plus(content))
920 | response = requests.request(
921 | method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body
922 | )
923 |
924 | if response.status_code == 200:
925 | print("自定义通知推送成功!")
926 | else:
927 | print(f"自定义通知推送失败!{response.status_code} {response.text}")
928 |
929 |
930 | def one() -> str:
931 | """
932 | 获取一条一言。
933 | :return:
934 | """
935 | url = "https://v1.hitokoto.cn/"
936 | res = requests.get(url).json()
937 | return res["hitokoto"] + " ----" + res["from"]
938 |
939 |
940 | def add_notify_function():
941 | notify_function = []
942 | if push_config.get("BARK_PUSH"):
943 | notify_function.append(bark)
944 | if push_config.get("CONSOLE"):
945 | notify_function.append(console)
946 | if push_config.get("DD_BOT_TOKEN") and push_config.get("DD_BOT_SECRET"):
947 | notify_function.append(dingding_bot)
948 | if push_config.get("FSKEY"):
949 | notify_function.append(feishu_bot)
950 | if push_config.get("GOBOT_URL") and push_config.get("GOBOT_QQ"):
951 | notify_function.append(go_cqhttp)
952 | if push_config.get("GOTIFY_URL") and push_config.get("GOTIFY_TOKEN"):
953 | notify_function.append(gotify)
954 | if push_config.get("IGOT_PUSH_KEY"):
955 | notify_function.append(iGot)
956 | if push_config.get("PUSH_KEY"):
957 | notify_function.append(serverJ)
958 | if push_config.get("DEER_KEY"):
959 | notify_function.append(pushdeer)
960 | if push_config.get("CHAT_URL") and push_config.get("CHAT_TOKEN"):
961 | notify_function.append(chat)
962 | if push_config.get("PUSH_PLUS_TOKEN"):
963 | notify_function.append(pushplus_bot)
964 | if push_config.get("WE_PLUS_BOT_TOKEN"):
965 | notify_function.append(weplus_bot)
966 | if push_config.get("QMSG_KEY") and push_config.get("QMSG_TYPE"):
967 | notify_function.append(qmsg_bot)
968 | if push_config.get("QYWX_AM"):
969 | notify_function.append(wecom_app)
970 | if push_config.get("QYWX_KEY"):
971 | notify_function.append(wecom_bot)
972 | if push_config.get("TG_BOT_TOKEN") and push_config.get("TG_USER_ID"):
973 | notify_function.append(telegram_bot)
974 | if (
975 | push_config.get("AIBOTK_KEY")
976 | and push_config.get("AIBOTK_TYPE")
977 | and push_config.get("AIBOTK_NAME")
978 | ):
979 | notify_function.append(aibotk)
980 | if (
981 | push_config.get("SMTP_SERVER")
982 | and push_config.get("SMTP_SSL")
983 | and push_config.get("SMTP_EMAIL")
984 | and push_config.get("SMTP_PASSWORD")
985 | and push_config.get("SMTP_NAME")
986 | ):
987 | notify_function.append(smtp)
988 | if push_config.get("PUSHME_KEY"):
989 | notify_function.append(pushme)
990 | if (
991 | push_config.get("CHRONOCAT_URL")
992 | and push_config.get("CHRONOCAT_QQ")
993 | and push_config.get("CHRONOCAT_TOKEN")
994 | ):
995 | notify_function.append(chronocat)
996 | if push_config.get("WEBHOOK_URL") and push_config.get("WEBHOOK_METHOD"):
997 | notify_function.append(custom_notify)
998 | if push_config.get("NTFY_TOPIC"):
999 | notify_function.append(ntfy)
1000 | if not notify_function:
1001 | print(f"无推送渠道,请检查通知变量是否正确")
1002 | return notify_function
1003 |
1004 |
1005 | def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
1006 | if kwargs:
1007 | global push_config
1008 | if ignore_default_config:
1009 | push_config = kwargs # 清空从环境变量获取的配置
1010 | else:
1011 | push_config.update(kwargs)
1012 |
1013 | if not content:
1014 | print(f"{title} 推送内容为空!")
1015 | return
1016 |
1017 | # 根据标题跳过一些消息推送,环境变量:SKIP_PUSH_TITLE 用回车分隔
1018 | skipTitle = os.getenv("SKIP_PUSH_TITLE")
1019 | if skipTitle:
1020 | if title in re.split("\n", skipTitle):
1021 | print(f"{title} 在SKIP_PUSH_TITLE环境变量内,跳过推送!")
1022 | return
1023 |
1024 | hitokoto = push_config.get("HITOKOTO")
1025 | if hitokoto and str(hitokoto).lower() != "false":
1026 | content += "\n\n" + one()
1027 |
1028 | notify_function = add_notify_function()
1029 | ts = [
1030 | threading.Thread(target=mode, args=(title, content), name=mode.__name__)
1031 | for mode in notify_function
1032 | ]
1033 | [t.start() for t in ts]
1034 | [t.join() for t in ts]
1035 |
1036 |
1037 | def main():
1038 | send("title", "content")
1039 |
1040 |
1041 | if __name__ == "__main__":
1042 | main()
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pycryptodome
2 | requests
--------------------------------------------------------------------------------
/telecom_class.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # _*_ coding:utf-8 _*_
3 |
4 | import re
5 | import base64
6 | import random
7 | import requests
8 | from datetime import datetime
9 | from Crypto.PublicKey import RSA
10 | from Crypto.Cipher import PKCS1_v1_5
11 |
12 |
13 | class Telecom:
14 | def __init__(self):
15 | self.login_info = {}
16 | self.phonenum = None
17 | self.password = None
18 | self.token = None
19 | self.client_type = "#9.7.0#channel50#iPhone 14 Pro#"
20 | self.headers = {
21 | "Accept": "application/json",
22 | "Content-Type": "application/json; charset=UTF-8",
23 | "Connection": "Keep-Alive",
24 | "Accept-Encoding": "gzip",
25 | "user-agent": "iPhone 14 Pro/9.7.0",
26 | }
27 |
28 | def set_login_info(self, login_info):
29 | self.login_info = login_info
30 | self.phonenum = login_info.get("phoneNbr", None)
31 | self.password = login_info.get("password", None)
32 | self.token = login_info.get("token", None)
33 |
34 | def trans_number(self, phonenum, encode=True):
35 | result = ""
36 | caesar_size = 2 if encode else -2
37 | for char in phonenum:
38 | result += chr(ord(char) + caesar_size & 65535)
39 | return result
40 |
41 | def encrypt(self, str):
42 | public_key_pem = """-----BEGIN PUBLIC KEY-----
43 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDBkLT15ThVgz6/NOl6s8GNPofd
44 | WzWbCkWnkaAm7O2LjkM1H7dMvzkiqdxU02jamGRHLX/ZNMCXHnPcW/sDhiFCBN18
45 | qFvy8g6VYb9QtroI09e176s+ZCtiv7hbin2cCTj99iUpnEloZm19lwHyo69u5UMi
46 | PMpq0/XKBO8lYhN/gwIDAQAB
47 | -----END PUBLIC KEY-----"""
48 | public_key = RSA.import_key(public_key_pem.encode())
49 | cipher = PKCS1_v1_5.new(public_key)
50 | ciphertext = cipher.encrypt(str.encode())
51 | encoded_ciphertext = base64.b64encode(ciphertext).decode()
52 | return encoded_ciphertext
53 |
54 | def get_fee_flow_limit(self, fee_remain_flow):
55 | today = datetime.today()
56 | days_in_month = (
57 | datetime(today.year, today.month + 1, 1)
58 | - datetime(today.year, today.month, 1)
59 | ).days
60 | return int((fee_remain_flow / days_in_month))
61 |
62 | def do_login(self, phonenum, password):
63 | phonenum = phonenum or self.phonenum
64 | password = password or self.password
65 | uuid = str(random.randint(1000000000000000, 9999999999999999))
66 | ts = datetime.now().strftime("%Y%m%d%H%M%S")
67 | enc_str = f"iPhone 14 13.2.{uuid[:12]}{phonenum}{ts}{password}0$$$0."
68 | body = {
69 | "content": {
70 | "fieldData": {
71 | "accountType": "",
72 | "authentication": self.trans_number(password),
73 | "deviceUid": uuid[:16],
74 | "isChinatelecom": "0",
75 | "loginAuthCipherAsymmertric": self.encrypt(enc_str),
76 | "loginType": "4",
77 | "phoneNum": self.trans_number(phonenum),
78 | "systemVersion": "13.2.3",
79 | },
80 | "attach": "test",
81 | },
82 | "headerInfos": {
83 | "code": "userLoginNormal",
84 | "clientType": self.client_type,
85 | "timestamp": ts,
86 | "shopId": "20002",
87 | "source": "110003",
88 | "sourcePassword": "Sid98s",
89 | "userLoginName": phonenum,
90 | },
91 | }
92 | response = requests.post(
93 | "https://appgologin.189.cn:9031/login/client/userLoginNormal",
94 | headers=self.headers,
95 | json=body,
96 | )
97 | return response.json()
98 |
99 | def qry_important_data(self, **kwargs):
100 | ts = datetime.now().strftime("%Y%m%d%H%M00")
101 | body = {
102 | "content": {
103 | "fieldData": {
104 | "provinceCode": self.login_info["provinceCode"] or "600101",
105 | "cityCode": self.login_info["cityCode"] or "8441900",
106 | "shopId": "20002",
107 | "isChinatelecom": "0",
108 | "account": self.trans_number(self.phonenum),
109 | },
110 | "attach": "test",
111 | },
112 | "headerInfos": {
113 | "code": "userFluxPackage",
114 | "clientType": self.client_type,
115 | "timestamp": ts,
116 | "shopId": "20002",
117 | "source": "110003",
118 | "sourcePassword": "Sid98s",
119 | "userLoginName": self.phonenum,
120 | "token": kwargs.get("token") or self.token,
121 | },
122 | }
123 | response = requests.post(
124 | "https://appfuwu.189.cn:9021/query/qryImportantData",
125 | headers=self.headers,
126 | json=body,
127 | )
128 | # print(response.text)
129 | return response.json()
130 |
131 | def user_flux_package(self, **kwargs):
132 | ts = datetime.now().strftime("%Y%m%d%H%M00")
133 | body = {
134 | "content": {
135 | "fieldData": {
136 | "queryFlag": "0",
137 | "accessAuth": "1",
138 | "account": self.trans_number(self.phonenum),
139 | },
140 | "attach": "test",
141 | },
142 | "headerInfos": {
143 | "code": "userFluxPackage",
144 | "clientType": self.client_type,
145 | "timestamp": ts,
146 | "shopId": "20002",
147 | "source": "110003",
148 | "sourcePassword": "Sid98s",
149 | "userLoginName": self.phonenum,
150 | "token": kwargs.get("token") or self.token,
151 | },
152 | }
153 | response = requests.post(
154 | "https://appfuwu.189.cn:9021/query/userFluxPackage",
155 | headers=self.headers,
156 | json=body,
157 | )
158 | # print(response.text)
159 | return response.json()
160 |
161 | def qry_share_usage(self, **kwargs):
162 | billing_cycle = kwargs.get("billing_cycle") or datetime.now().strftime("%Y%m")
163 | ts = datetime.now().strftime("%Y%m%d%H%M00")
164 | body = {
165 | "content": {
166 | "attach": "test",
167 | "fieldData": {
168 | "billingCycle": billing_cycle,
169 | "account": self.trans_number(self.phonenum),
170 | },
171 | },
172 | "headerInfos": {
173 | "code": "qryShareUsage",
174 | "clientType": self.client_type,
175 | "timestamp": ts,
176 | "shopId": "20002",
177 | "source": "110003",
178 | "sourcePassword": "Sid98s",
179 | "userLoginName": self.phonenum,
180 | "token": kwargs.get("token") or self.token,
181 | },
182 | }
183 | response = requests.post(
184 | "https://appfuwu.189.cn:9021/query/qryShareUsage",
185 | headers=self.headers,
186 | json=body,
187 | )
188 | data = response.json()
189 | # 返回的号码字段加密,需做解密转换
190 | if data.get("responseData").get("data").get("sharePhoneBeans"):
191 | for item in data["responseData"]["data"]["sharePhoneBeans"]:
192 | item["sharePhoneNum"] = self.trans_number(item["sharePhoneNum"], False)
193 | for share_type in data["responseData"]["data"]["shareTypeBeans"]:
194 | for share_info in share_type["shareUsageInfos"]:
195 | for share_amount in share_info["shareUsageAmounts"]:
196 | share_amount["phoneNum"] = self.trans_number(
197 | share_amount["phoneNum"], False
198 | )
199 | return data
200 |
201 | def to_summary(self, data, phonenum=""):
202 | if not data:
203 | return {}
204 | phonenum = phonenum or self.phonenum
205 | # 总流量
206 | flow_use = int(data["flowInfo"]["totalAmount"]["used"] or 0)
207 | flow_balance = int(data["flowInfo"]["totalAmount"]["balance"] or 0)
208 | flow_total = flow_use + flow_balance
209 | flow_over = int(data["flowInfo"]["totalAmount"]["over"] or 0)
210 | # 通用流量
211 | common_use = int(data["flowInfo"]["commonFlow"]["used"] or 0)
212 | common_balance = int(data["flowInfo"]["commonFlow"]["balance"] or 0)
213 | common_total = common_use + common_balance
214 | common_over = int(data["flowInfo"]["commonFlow"]["over"] or 0)
215 | # 专用流量
216 | special_use = (
217 | int(data["flowInfo"]["specialAmount"]["used"] or 0)
218 | if data["flowInfo"].get("specialAmount")
219 | else 0
220 | )
221 | special_balance = (
222 | int(data["flowInfo"]["specialAmount"]["balance"] or 0)
223 | if data["flowInfo"].get("specialAmount")
224 | else 0
225 | )
226 | special_total = special_use + special_balance
227 | # 语音通话
228 | voice_usage = int(data["voiceInfo"]["voiceDataInfo"]["used"] or 0)
229 | voice_balance = int(data["voiceInfo"]["voiceDataInfo"]["balance"] or 0)
230 | voice_total = int(data["voiceInfo"]["voiceDataInfo"]["total"] or 0)
231 | # 余额
232 | balance = int(
233 | float(data["balanceInfo"]["indexBalanceDataInfo"]["balance"] or 0) * 100
234 | )
235 | # 流量包列表
236 | flowItems = []
237 | flow_lists = data.get("flowInfo", {}).get("flowList", [])
238 | for item in flow_lists:
239 | if "流量" not in item["title"]:
240 | continue
241 | # 常规流量
242 | if "已用" in item["leftTitle"] and "剩余" in item["rightTitle"]:
243 | item_use = self.convert_flow(item["leftTitleHh"], "KB")
244 | item_balance = self.convert_flow(item["rightTitleHh"], "KB")
245 | item_total = item_use + item_balance
246 | # 常规流量,超流量
247 | elif "超出" in item["leftTitle"] and "/" in item["rightTitleEnd"]:
248 | item_balance = -self.convert_flow(item["leftTitleHh"], "KB")
249 | item_use = (
250 | self.convert_flow(item["rightTitleEnd"].split("/")[1], "KB")
251 | - item_balance
252 | )
253 | item_total = item_use + item_balance
254 | # 无限流量,达量降速
255 | elif "已用" in item["leftTitle"] and "降速" in item["rightTitle"]:
256 | item_total = self.convert_flow(
257 | re.search(r"(\d+[KMGT]B)", item["rightTitle"]).group(1), "KB"
258 | )
259 | item_use = self.convert_flow(item["leftTitleHh"], "KB")
260 | item_balance = item_total - item_use
261 | flowItems.append(
262 | {
263 | "name": item["title"],
264 | "use": item_use,
265 | "balance": item_balance,
266 | "total": item_total,
267 | }
268 | )
269 | summary = {
270 | "phonenum": phonenum,
271 | "balance": balance,
272 | "voiceUsage": voice_usage,
273 | "voiceTotal": voice_total,
274 | "flowUse": flow_use,
275 | "flowTotal": flow_total,
276 | "flowOver": flow_over,
277 | "commonUse": common_use,
278 | "commonTotal": common_total,
279 | "commonOver": common_over,
280 | "specialUse": special_use,
281 | "specialTotal": special_total,
282 | "createTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
283 | "flowItems": flowItems,
284 | }
285 | return summary
286 |
287 | def convert_flow(self, size_str, target_unit="KB", decimal=0):
288 | unit_dict = {"KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}
289 | if not size_str:
290 | return 0
291 | if isinstance(size_str, str):
292 | size, unit = float(size_str[:-2]), size_str[-2:]
293 | elif isinstance(size_str, (int, float)):
294 | size, unit = size_str, "KB"
295 | if unit in unit_dict or target_unit in unit_dict:
296 | return (
297 | int(size * unit_dict[unit] / unit_dict[target_unit])
298 | if decimal == 0
299 | else round(size * unit_dict[unit] / unit_dict[target_unit], decimal)
300 | )
301 | else:
302 | raise ValueError("Invalid unit")
303 |
--------------------------------------------------------------------------------
/telecom_monitor.py:
--------------------------------------------------------------------------------
1 | # !/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # Repo: https://github.com/Cp0204/ChinaTelecomMonitor
4 | # ConfigFile: telecom_config.json
5 | # Modify: 2024-05-11
6 |
7 | """
8 | 任务名称
9 | name: 电信套餐用量监控
10 | 定时规则
11 | cron: 0 20 * * *
12 | """
13 |
14 | import os
15 | import sys
16 | import json
17 | from datetime import datetime
18 |
19 | # 兼容青龙
20 | try:
21 | from telecom_class import Telecom
22 | except:
23 | print("正在尝试自动安装依赖...")
24 | os.system("pip3 install pycryptodome requests &> /dev/null")
25 | from telecom_class import Telecom
26 |
27 |
28 | CONFIG_DATA = {}
29 | NOTIFYS = []
30 | CONFIG_PATH = sys.argv[1] if len(sys.argv) > 1 else "telecom_config.json"
31 |
32 |
33 | # 发送通知消息
34 | def send_notify(title, body):
35 | try:
36 | # 导入通知模块
37 | import notify
38 |
39 | # 如未配置 push_config 则使用青龙环境通知设置
40 | if CONFIG_DATA.get("push_config"):
41 | notify.push_config = CONFIG_DATA["push_config"].copy()
42 | notify.push_config["CONSOLE"] = notify.push_config.get("CONSOLE", True)
43 | notify.send(title, body)
44 | except Exception as e:
45 | if e:
46 | print("发送通知消息失败!")
47 |
48 |
49 | # 添加消息
50 | def add_notify(text):
51 | global NOTIFYS
52 | NOTIFYS.append(text)
53 | print("📢", text)
54 | return text
55 |
56 |
57 | def main():
58 | global CONFIG_DATA
59 | start_time = datetime.now()
60 | print(f"===============程序开始===============")
61 | print(f"⏰ 执行时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
62 | print()
63 | # 读取配置
64 | if os.path.exists(CONFIG_PATH):
65 | print(f"⚙️ 正从 {CONFIG_PATH} 文件中读取配置")
66 | with open(CONFIG_PATH, "r", encoding="utf-8") as file:
67 | CONFIG_DATA = json.load(file)
68 | if not CONFIG_DATA.get("user"):
69 | CONFIG_DATA["user"] = {}
70 |
71 | telecom = Telecom()
72 |
73 | def auto_login():
74 | if TELECOM_USER := os.environ.get("TELECOM_USER"):
75 | phonenum, password = (
76 | TELECOM_USER[:11],
77 | TELECOM_USER[11:],
78 | )
79 | elif TELECOM_USER := CONFIG_DATA.get("user", {}):
80 | phonenum, password = (
81 | TELECOM_USER.get("phonenum", ""),
82 | TELECOM_USER.get("password", ""),
83 | )
84 | else:
85 | exit("自动登录:未设置账号密码,退出")
86 | if not phonenum.isdigit():
87 | exit("自动登录:手机号设置错误,退出")
88 | else:
89 | print(f"自动登录:{phonenum}")
90 | login_failure_count = CONFIG_DATA.get("user", {}).get("loginFailureCount", 0)
91 | if login_failure_count < 5:
92 | data = telecom.do_login(phonenum, password)
93 | if data.get("responseData").get("resultCode") == "0000":
94 | print(f"自动登录:成功")
95 | login_info = data["responseData"]["data"]["loginSuccessResult"]
96 | login_info["createTime"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
97 | CONFIG_DATA["login_info"] = login_info
98 | telecom.set_login_info(login_info)
99 | else:
100 | login_failure_count += 1
101 | CONFIG_DATA["user"]["loginFailureCount"] = login_failure_count
102 | update_config()
103 | add_notify(f"自动登录:记录失败{login_failure_count}次,程序退出")
104 | exit(data)
105 | else:
106 | print(f"自动登录:记录失败{login_failure_count}次,跳过执行")
107 | exit()
108 |
109 | # 读取缓存Token
110 | login_info = CONFIG_DATA.get("login_info", {})
111 | if login_info:
112 | print(f"尝试使用缓存登录:{login_info['phoneNbr']}")
113 | telecom.set_login_info(login_info)
114 | else:
115 | auto_login()
116 |
117 | # 获取主要信息
118 | important_data = telecom.qry_important_data()
119 | if important_data.get("responseData"):
120 | print(f"获取主要信息:成功")
121 | elif important_data["headerInfos"]["code"] == "X201":
122 | print(f"获取主要信息:失败 {important_data['headerInfos']['reason']}")
123 | auto_login()
124 | important_data = telecom.qry_important_data()
125 |
126 | # 简化主要信息
127 | try:
128 | summary = telecom.to_summary(important_data["responseData"]["data"])
129 | except Exception as e:
130 | exit(
131 | f"简化主要信息出错,提 Issue 请提供以下信息(隐私打码):\n\n{json.dumps(important_data['responseData']['data'], ensure_ascii=False)}\n\n{e}"
132 | )
133 | if summary:
134 | print(f"简化主要信息:{summary}")
135 | CONFIG_DATA["summary"] = summary
136 |
137 | # 获取流量包明细
138 | flux_package_str = ""
139 | user_flux_package = telecom.user_flux_package()
140 | if user_flux_package:
141 | print("获取流量包明细:成功")
142 | packages = user_flux_package["responseData"]["data"]["productOFFRatable"][
143 | "ratableResourcePackages"
144 | ]
145 | for package in packages:
146 | package_icon = (
147 | "🇨🇳"
148 | if "国内" in package["title"]
149 | else "📺" if "专用" in package["title"] else "🌎"
150 | )
151 | flux_package_str += f"\n{package_icon}{package['title']}\n"
152 | for product in package["productInfos"]:
153 | if product["infiniteTitle"]:
154 | # 无限流量
155 | flux_package_str += f"""🔹[{product['title']}]{product['infiniteTitle']}{product['infiniteValue']}{product['infiniteUnit']}/无限\n"""
156 | else:
157 | flux_package_str += f"""🔹[{product['title']}]{product['leftTitle']}{product['leftHighlight']}{product['rightCommon']}\n"""
158 | # 流量字符串
159 | common_str = (
160 | f"{telecom.convert_flow(summary['commonUse'],'GB',2)} / {telecom.convert_flow(summary['commonTotal'],'GB',2)} GB 🟢"
161 | if summary["flowOver"] == 0
162 | else f"-{telecom.convert_flow(summary['flowOver'],'GB',2)} / {telecom.convert_flow(summary['commonTotal'],'GB',2)} GB 🔴"
163 | )
164 | special_str = (
165 | f"{telecom.convert_flow(summary['specialUse'], 'GB', 2)} / {telecom.convert_flow(summary['specialTotal'], 'GB', 2)} GB"
166 | if summary["specialTotal"] > 0
167 | else ""
168 | )
169 | # 添加通知
170 | add_notify(
171 | f"""
172 | 📱 手机:{summary['phonenum']}
173 | 💰 余额:{round(summary['balance']/100,2)}
174 | 📞 通话:{summary['voiceUsage']}{f" / {summary['voiceTotal']}" if summary['voiceTotal']>0 else ""} min
175 | 🌐 总流量
176 | - 通用:{common_str}{f"{chr(10)} - 专用:{special_str}" if special_str else ""}
177 |
178 | 【流量包明细】
179 |
180 | {flux_package_str.strip()}
181 |
182 | 查询时间:{summary['createTime']}
183 | """.strip()
184 | )
185 |
186 | # 通知
187 | if NOTIFYS:
188 | notify_body = "\n".join(NOTIFYS)
189 | print(f"===============推送通知===============")
190 | send_notify("【电信套餐用量监控】", notify_body)
191 | print()
192 |
193 | update_config()
194 |
195 |
196 | def update_config():
197 | # 更新配置
198 | with open(CONFIG_PATH, "w", encoding="utf-8") as file:
199 | json.dump(CONFIG_DATA, file, ensure_ascii=False, indent=2)
200 |
201 |
202 | if __name__ == "__main__":
203 | main()
204 |
--------------------------------------------------------------------------------